返回 DeepSeek-Reasonix
netclient_test.go
根目录 / internal / netclient / netclient_test.go
1 package netclient
2
3 import (
4 "bufio"
5 "context"
6 "crypto/tls"
7 "encoding/binary"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strconv"
14 "strings"
15 "sync/atomic"
16 "testing"
17 "time"
18 )
19
20 func TestCustomProxyBuildsSocks5URL(t *testing.T) {
21 pf, err := proxyFunc(ProxySpec{
22 Mode: "custom",
23 Type: "socks5",
24 Server: "127.0.0.1",
25 Port: 7890,
26 Username: "user",
27 Password: "secret",
28 })
29 if err != nil {
30 t.Fatalf("proxyFunc: %v", err)
31 }
32 got, err := pf(&http.Request{URL: mustURL("https://api.deepseek.com/chat/completions")})
33 if err != nil {
34 t.Fatalf("proxy lookup: %v", err)
35 }
36 if got.Scheme != "socks5" || got.Host != "127.0.0.1:7890" {
37 t.Fatalf("proxy URL = %s, want socks5://127.0.0.1:7890", got)
38 }
39 if pass, ok := got.User.Password(); !ok || pass != "secret" {
40 t.Fatalf("proxy password not preserved")
41 }
42 }
43
44 func TestCustomProxyHonorsNoProxy(t *testing.T) {
45 pf, err := proxyFunc(ProxySpec{
46 Mode: "custom",
47 URL: "http://proxy.example.com:8080",
48 NoProxy: "api.deepseek.com",
49 })
50 if err != nil {
51 t.Fatalf("proxyFunc: %v", err)
52 }
53 got, err := pf(&http.Request{URL: mustURL("https://api.deepseek.com/chat/completions")})
54 if err != nil {
55 t.Fatalf("proxy lookup: %v", err)
56 }
57 if got != nil {
58 t.Fatalf("NoProxy host should bypass proxy, got %s", got)
59 }
60 }
61
62 func TestDirectHostsBypassProxy(t *testing.T) {
63 t.Setenv("HTTPS_PROXY", "http://proxy.example.com:8080")
64 t.Setenv("NO_PROXY", "")
65 pf, err := proxyFunc(ProxySpec{Mode: "auto", DirectHosts: []string{"token-plan-cn.xiaomimimo.com"}})
66 if err != nil {
67 t.Fatalf("proxyFunc: %v", err)
68 }
69
70 got, err := pf(&http.Request{URL: mustURL("https://token-plan-cn.xiaomimimo.com/v1/chat")})
71 if err != nil {
72 t.Fatalf("direct-host lookup: %v", err)
73 }
74 if got != nil {
75 t.Fatalf("a direct host should bypass the proxy, got %s", got)
76 }
77
78 other, err := pf(&http.Request{URL: mustURL("https://example.com/x")})
79 if err != nil {
80 t.Fatalf("other lookup: %v", err)
81 }
82 if other == nil || other.Host != "proxy.example.com:8080" {
83 t.Fatalf("non-direct host should still use the env proxy, got %v", other)
84 }
85 }
86
87 func TestNoDirectHostsKeepsEveryoneProxied(t *testing.T) {
88 t.Setenv("HTTPS_PROXY", "http://proxy.example.com:8080")
89 t.Setenv("NO_PROXY", "")
90 pf, err := proxyFunc(ProxySpec{Mode: "env"}) // no DirectHosts → nothing special-cased
91 if err != nil {
92 t.Fatalf("proxyFunc: %v", err)
93 }
94 got, err := pf(&http.Request{URL: mustURL("https://token-plan-cn.xiaomimimo.com/v1/chat")})
95 if err != nil {
96 t.Fatalf("lookup: %v", err)
97 }
98 if got == nil || got.Host != "proxy.example.com:8080" {
99 t.Fatalf("without DirectHosts the host must go through the proxy, got %v", got)
100 }
101 }
102
103 func TestOffProxyDisablesProxy(t *testing.T) {
104 pf, err := proxyFunc(ProxySpec{Mode: "off"})
105 if err != nil {
106 t.Fatalf("proxyFunc: %v", err)
107 }
108 if pf != nil {
109 t.Fatal("off mode should return nil proxy func")
110 }
111 }
112
113 func TestSummaryRedactsPassword(t *testing.T) {
114 got := Summary(ProxySpec{
115 Mode: "custom",
116 Type: "socks5",
117 Server: "proxy.example.com",
118 Port: 1080,
119 Username: "user",
120 Password: "secret",
121 })
122 if got != "custom (socks5://user@proxy.example.com:1080)" {
123 t.Fatalf("Summary = %q", got)
124 }
125 }
126
127 func TestHTTPClientProxyModesAffectRequests(t *testing.T) {
128 var targetHits int32
129 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130 atomic.AddInt32(&targetHits, 1)
131 _, _ = io.WriteString(w, "target")
132 }))
133 t.Cleanup(target.Close)
134 targetAddr := strings.TrimPrefix(target.URL, "http://")
135
136 var envProxyHits int32
137 envProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 atomic.AddInt32(&envProxyHits, 1)
139 if got, want := r.URL.String(), "http://service.test/resource"; got != want {
140 t.Errorf("env proxy request URL = %q, want %q", got, want)
141 }
142 _, _ = io.WriteString(w, "env-proxy")
143 }))
144 t.Cleanup(envProxy.Close)
145
146 var customProxyHits int32
147 customProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148 atomic.AddInt32(&customProxyHits, 1)
149 if got, want := r.URL.String(), "http://service.test/resource"; got != want {
150 t.Errorf("custom proxy request URL = %q, want %q", got, want)
151 }
152 _, _ = io.WriteString(w, "custom-proxy")
153 }))
154 t.Cleanup(customProxy.Close)
155
156 // Windows env vars are case-insensitive, so HTTP_PROXY and http_proxy are the
157 // same var — set the intended value last or the empty clear wipes it.
158 t.Setenv("http_proxy", "")
159 t.Setenv("HTTPS_PROXY", "")
160 t.Setenv("https_proxy", "")
161 t.Setenv("NO_PROXY", "")
162 t.Setenv("no_proxy", "")
163 t.Setenv("HTTP_PROXY", envProxy.URL)
164
165 tests := []struct {
166 name string
167 spec ProxySpec
168 wantBody string
169 wantTargetHits int32
170 wantEnvHits int32
171 wantCustomHits int32
172 }{
173 {
174 name: "auto uses environment proxy",
175 spec: ProxySpec{Mode: ModeAuto},
176 wantBody: "env-proxy",
177 wantEnvHits: 1,
178 },
179 {
180 name: "env uses environment proxy",
181 spec: ProxySpec{Mode: ModeEnv},
182 wantBody: "env-proxy",
183 wantEnvHits: 1,
184 },
185 {
186 name: "custom ignores environment proxy",
187 spec: ProxySpec{Mode: ModeCustom, URL: customProxy.URL},
188 wantBody: "custom-proxy",
189 wantCustomHits: 1,
190 },
191 {
192 name: "custom no_proxy bypasses proxy",
193 spec: ProxySpec{Mode: ModeCustom, URL: customProxy.URL, NoProxy: "service.test"},
194 wantBody: "target",
195 wantTargetHits: 1,
196 },
197 {
198 name: "off bypasses environment proxy",
199 spec: ProxySpec{Mode: ModeOff},
200 wantBody: "target",
201 wantTargetHits: 1,
202 },
203 }
204
205 for _, tt := range tests {
206 t.Run(tt.name, func(t *testing.T) {
207 atomic.StoreInt32(&targetHits, 0)
208 atomic.StoreInt32(&envProxyHits, 0)
209 atomic.StoreInt32(&customProxyHits, 0)
210
211 client := mappedClient(t, tt.spec, "service.test:80", targetAddr)
212 resp, err := client.Get("http://service.test/resource")
213 if err != nil {
214 t.Fatalf("GET: %v", err)
215 }
216 defer resp.Body.Close()
217 body, err := io.ReadAll(resp.Body)
218 if err != nil {
219 t.Fatalf("read body: %v", err)
220 }
221 if string(body) != tt.wantBody {
222 t.Fatalf("body = %q, want %q", body, tt.wantBody)
223 }
224 if got := atomic.LoadInt32(&targetHits); got != tt.wantTargetHits {
225 t.Fatalf("target hits = %d, want %d", got, tt.wantTargetHits)
226 }
227 if got := atomic.LoadInt32(&envProxyHits); got != tt.wantEnvHits {
228 t.Fatalf("env proxy hits = %d, want %d", got, tt.wantEnvHits)
229 }
230 if got := atomic.LoadInt32(&customProxyHits); got != tt.wantCustomHits {
231 t.Fatalf("custom proxy hits = %d, want %d", got, tt.wantCustomHits)
232 }
233 })
234 }
235 }
236
237 func TestStructuredProxyTypesAffectRequests(t *testing.T) {
238 httpProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
239 if got, want := r.URL.String(), "http://service.test/resource"; got != want {
240 t.Errorf("HTTP proxy request URL = %q, want %q", got, want)
241 }
242 _, _ = io.WriteString(w, "http-proxy")
243 }))
244 t.Cleanup(httpProxy.Close)
245
246 httpsProxy := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247 if got, want := r.URL.String(), "http://service.test/resource"; got != want {
248 t.Errorf("HTTPS proxy request URL = %q, want %q", got, want)
249 }
250 _, _ = io.WriteString(w, "https-proxy")
251 }))
252 t.Cleanup(httpsProxy.Close)
253
254 socks5Proxy := newSocksHTTPProxy(t)
255 socks5hProxy := newSocksHTTPProxy(t)
256
257 tests := []struct {
258 name string
259 spec ProxySpec
260 tlsProxy *httptest.Server
261 wantBody string
262 }{
263 {
264 name: "http",
265 spec: structuredProxySpec(t, "http", httpProxy.URL),
266 wantBody: "http-proxy",
267 },
268 {
269 name: "https",
270 spec: structuredProxySpec(t, "https", httpsProxy.URL),
271 tlsProxy: httpsProxy,
272 wantBody: "https-proxy",
273 },
274 {
275 name: "socks5",
276 spec: structuredProxySpec(t, "socks5", "http://"+socks5Proxy.addr),
277 wantBody: "socks-proxy",
278 },
279 {
280 name: "socks5h",
281 spec: structuredProxySpec(t, "socks5h", "http://"+socks5hProxy.addr),
282 wantBody: "socks-proxy",
283 },
284 }
285
286 for _, tt := range tests {
287 t.Run(tt.name, func(t *testing.T) {
288 tr := mappedTransport(t, tt.spec, "service.test:80", "127.0.0.1:1")
289 if tt.tlsProxy != nil {
290 proxyTransport := tt.tlsProxy.Client().Transport.(*http.Transport)
291 tr.TLSClientConfig = proxyTransport.TLSClientConfig
292 }
293 client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
294 resp, err := client.Get("http://service.test/resource")
295 if err != nil {
296 t.Fatalf("GET: %v", err)
297 }
298 defer resp.Body.Close()
299 body, err := io.ReadAll(resp.Body)
300 if err != nil {
301 t.Fatalf("read body: %v", err)
302 }
303 if string(body) != tt.wantBody {
304 t.Fatalf("body = %q, want %q", body, tt.wantBody)
305 }
306 })
307 }
308
309 if got := atomic.LoadInt32(&socks5Proxy.hits); got != 1 {
310 t.Fatalf("socks5 proxy hits = %d, want 1", got)
311 }
312 if got := atomic.LoadInt32(&socks5hProxy.hits); got != 1 {
313 t.Fatalf("socks5h proxy hits = %d, want 1", got)
314 }
315 }
316
317 func TestHTTPSRequestsRespectProxyModes(t *testing.T) {
318 var targetHits int32
319 target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
320 atomic.AddInt32(&targetHits, 1)
321 w.Header().Set("Connection", "close")
322 _, _ = io.WriteString(w, "https-target")
323 }))
324 t.Cleanup(target.Close)
325 targetAddr := strings.TrimPrefix(target.URL, "https://")
326
327 var proxyHits int32
328 proxy := newConnectProxy(t, targetAddr, &proxyHits)
329 t.Cleanup(proxy.Close)
330
331 // Set HTTPS_PROXY last: on Windows it and https_proxy are the same var.
332 t.Setenv("HTTP_PROXY", "")
333 t.Setenv("http_proxy", "")
334 t.Setenv("https_proxy", "")
335 t.Setenv("NO_PROXY", "")
336 t.Setenv("no_proxy", "")
337 t.Setenv("HTTPS_PROXY", proxy.URL)
338
339 tests := []struct {
340 name string
341 spec ProxySpec
342 wantProxyHits int32
343 }{
344 {
345 name: "auto uses HTTPS_PROXY",
346 spec: ProxySpec{Mode: ModeAuto},
347 wantProxyHits: 1,
348 },
349 {
350 name: "custom proxies HTTPS requests",
351 spec: ProxySpec{Mode: ModeCustom, URL: proxy.URL},
352 wantProxyHits: 1,
353 },
354 {
355 name: "custom no_proxy bypasses HTTPS proxy",
356 spec: ProxySpec{Mode: ModeCustom, URL: proxy.URL, NoProxy: "service.test"},
357 },
358 {
359 name: "off bypasses HTTPS_PROXY",
360 spec: ProxySpec{Mode: ModeOff},
361 },
362 }
363
364 for _, tt := range tests {
365 t.Run(tt.name, func(t *testing.T) {
366 atomic.StoreInt32(&targetHits, 0)
367 atomic.StoreInt32(&proxyHits, 0)
368
369 tr := mappedTransport(t, tt.spec, "service.test:443", targetAddr)
370 tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
371 client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
372 resp, err := client.Get("https://service.test/resource")
373 if err != nil {
374 t.Fatalf("GET: %v", err)
375 }
376 defer resp.Body.Close()
377 body, err := io.ReadAll(resp.Body)
378 if err != nil {
379 t.Fatalf("read body: %v", err)
380 }
381 if string(body) != "https-target" {
382 t.Fatalf("body = %q, want https-target", body)
383 }
384 if got := atomic.LoadInt32(&targetHits); got != 1 {
385 t.Fatalf("target hits = %d, want 1", got)
386 }
387 if got := atomic.LoadInt32(&proxyHits); got != tt.wantProxyHits {
388 t.Fatalf("CONNECT proxy hits = %d, want %d", got, tt.wantProxyHits)
389 }
390 })
391 }
392 }
393
394 func structuredProxySpec(t *testing.T, typ, rawURL string) ProxySpec {
395 t.Helper()
396 u, err := url.Parse(rawURL)
397 if err != nil {
398 t.Fatalf("parse proxy URL: %v", err)
399 }
400 host, portText, err := net.SplitHostPort(u.Host)
401 if err != nil {
402 t.Fatalf("split proxy host: %v", err)
403 }
404 port, err := strconv.Atoi(portText)
405 if err != nil {
406 t.Fatalf("parse proxy port: %v", err)
407 }
408 return ProxySpec{Mode: ModeCustom, Type: typ, Server: host, Port: port}
409 }
410
411 func mappedClient(t *testing.T, spec ProxySpec, fromAddr, toAddr string) *http.Client {
412 t.Helper()
413 tr := mappedTransport(t, spec, fromAddr, toAddr)
414 return &http.Client{Transport: tr, Timeout: 2 * time.Second}
415 }
416
417 func mappedTransport(t *testing.T, spec ProxySpec, fromAddr, toAddr string) *http.Transport {
418 t.Helper()
419 tr, err := NewTransport(spec, TransportOptions{})
420 if err != nil {
421 t.Fatalf("NewTransport: %v", err)
422 }
423 t.Cleanup(tr.CloseIdleConnections)
424 dialer := &net.Dialer{Timeout: time.Second}
425 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
426 if addr == fromAddr {
427 addr = toAddr
428 }
429 return dialer.DialContext(ctx, network, addr)
430 }
431 return tr
432 }
433
434 type socksHTTPProxy struct {
435 addr string
436 hits int32
437 }
438
439 func newSocksHTTPProxy(t *testing.T) *socksHTTPProxy {
440 t.Helper()
441 ln, err := net.Listen("tcp", "127.0.0.1:0")
442 if err != nil {
443 t.Fatalf("listen socks proxy: %v", err)
444 }
445 p := &socksHTTPProxy{addr: ln.Addr().String()}
446 t.Cleanup(func() { _ = ln.Close() })
447 go func() {
448 for {
449 conn, err := ln.Accept()
450 if err != nil {
451 return
452 }
453 go p.handle(conn)
454 }
455 }()
456 return p
457 }
458
459 func (p *socksHTTPProxy) handle(conn net.Conn) {
460 defer conn.Close()
461 _ = conn.SetDeadline(time.Now().Add(2 * time.Second))
462 r := bufio.NewReader(conn)
463
464 header := make([]byte, 2)
465 if _, err := io.ReadFull(r, header); err != nil || header[0] != 5 {
466 return
467 }
468 methods := make([]byte, int(header[1]))
469 if _, err := io.ReadFull(r, methods); err != nil {
470 return
471 }
472 if _, err := conn.Write([]byte{5, 0}); err != nil {
473 return
474 }
475
476 req := make([]byte, 4)
477 if _, err := io.ReadFull(r, req); err != nil || req[0] != 5 || req[1] != 1 {
478 return
479 }
480 if !p.readSocksAddr(r, req[3]) {
481 return
482 }
483 atomic.AddInt32(&p.hits, 1)
484 if _, err := conn.Write([]byte{5, 0, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil {
485 return
486 }
487
488 httpReq, err := http.ReadRequest(r)
489 if err != nil {
490 return
491 }
492 _ = httpReq.Body.Close()
493 _, _ = conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 11\r\nConnection: close\r\n\r\nsocks-proxy"))
494 }
495
496 func (p *socksHTTPProxy) readSocksAddr(r *bufio.Reader, atyp byte) bool {
497 switch atyp {
498 case 1:
499 if _, err := io.ReadFull(r, make([]byte, net.IPv4len)); err != nil {
500 return false
501 }
502 case 3:
503 size, err := r.ReadByte()
504 if err != nil {
505 return false
506 }
507 if _, err := io.ReadFull(r, make([]byte, int(size))); err != nil {
508 return false
509 }
510 case 4:
511 if _, err := io.ReadFull(r, make([]byte, net.IPv6len)); err != nil {
512 return false
513 }
514 default:
515 return false
516 }
517 port := make([]byte, 2)
518 if _, err := io.ReadFull(r, port); err != nil {
519 return false
520 }
521 return binary.BigEndian.Uint16(port) != 0
522 }
523
524 func newConnectProxy(t *testing.T, targetAddr string, hits *int32) *httptest.Server {
525 t.Helper()
526 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
527 if r.Method != http.MethodConnect {
528 t.Errorf("proxy method = %s, want CONNECT", r.Method)
529 http.Error(w, "CONNECT required", http.StatusMethodNotAllowed)
530 return
531 }
532 atomic.AddInt32(hits, 1)
533 clientConn, _, err := http.NewResponseController(w).Hijack()
534 if err != nil {
535 t.Errorf("hijack CONNECT: %v", err)
536 return
537 }
538 defer clientConn.Close()
539 targetConn, err := net.DialTimeout("tcp", targetAddr, time.Second)
540 if err != nil {
541 t.Errorf("dial CONNECT target: %v", err)
542 return
543 }
544 defer targetConn.Close()
545 if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
546 return
547 }
548 go func() {
549 _, _ = io.Copy(targetConn, clientConn)
550 }()
551 _, _ = io.Copy(clientConn, targetConn)
552 }))
553 }
554
555 func mustURL(s string) *url.URL {
556 u, err := url.Parse(s)
557 if err != nil {
558 panic(err)
559 }
560 return u
561 }
562
563 func TestForceIPv4Dials(t *testing.T) {
564 tr, err := NewTransport(ProxySpec{Mode: ModeOff}, TransportOptions{ForceIPv4: true})
565 if err != nil {
566 t.Fatal(err)
567 }
568 if tr.DialContext == nil {
569 t.Fatal("ForceIPv4 should install a DialContext")
570 }
571 ln, err := net.Listen("tcp4", "127.0.0.1:0")
572 if err != nil {
573 t.Fatal(err)
574 }
575 defer ln.Close()
576
577 conn, err := tr.DialContext(context.Background(), "tcp", ln.Addr().String())
578 if err != nil {
579 t.Fatalf("forced-IPv4 dial to a v4 listener failed: %v", err)
580 }
581 conn.Close()
582
583 // A tcp4 dialer rejects an IPv6 literal outright (address-family mismatch),
584 // which is exactly the IPv6 route the fallback is meant to skip.
585 if c, err := tr.DialContext(context.Background(), "tcp", "[::1]:9"); err == nil {
586 c.Close()
587 t.Error("forced-IPv4 dial should reject an IPv6 address")
588 }
589 }
590
590 lines GO