Skip to content

Commit fe86867

Browse files
mapleafgoclaude
andcommitted
test(core): add SOCKS5 and HTTPS connectivity integration tests
Add TestConnectivity_SOCKS5 using golang.org/x/net/proxy to verify SOCKS5 dial through the mixed inbound, and TestConnectivity_HTTPS using HTTP CONNECT to verify TLS negotiation with gstatic.com. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 377693e commit fe86867

2 files changed

Lines changed: 274 additions & 1 deletion

File tree

core/connect_test.go

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"testing"
1616
"time"
1717

18+
"golang.org/x/net/proxy"
19+
1820
"github.com/mapleafgo/singcast/translator"
1921
)
2022

@@ -157,6 +159,277 @@ func TestConnectivity_Google(t *testing.T) {
157159
}
158160
}
159161

162+
// TestConnectivity_SOCKS5 verifies that google.com is reachable through the
163+
// mixed proxy inbound using the SOCKS5 protocol (instead of HTTP CONNECT).
164+
func TestConnectivity_SOCKS5(t *testing.T) {
165+
if _, err := os.Stat(realConfigPath); err != nil {
166+
t.Skipf("real config not found: %s", realConfigPath)
167+
}
168+
169+
data, err := os.ReadFile(realConfigPath)
170+
if err != nil {
171+
t.Fatalf("read config: %v", err)
172+
}
173+
174+
jsonContent, warns, err := translator.Translate(data)
175+
if err != nil {
176+
t.Fatalf("translate config: %v", err)
177+
}
178+
for _, w := range warns {
179+
t.Logf("WARN: %s", w)
180+
}
181+
182+
mixedPort := extractMixedPort(t, jsonContent)
183+
if mixedPort == 0 {
184+
t.Fatal("no mixed inbound port found in config")
185+
}
186+
187+
tmpDir := t.TempDir()
188+
homeDir := filepath.Join(tmpDir, "home")
189+
if err := os.MkdirAll(homeDir, 0o700); err != nil {
190+
t.Fatal(err)
191+
}
192+
configPath := filepath.Join(tmpDir, "config.json")
193+
if err := os.WriteFile(configPath, []byte(jsonContent), 0o600); err != nil {
194+
t.Fatal(err)
195+
}
196+
197+
if err := Init(homeDir); err != nil {
198+
t.Fatalf("Init: %v", err)
199+
}
200+
defer Close()
201+
202+
var (
203+
connMu sync.Mutex
204+
connEvents []string
205+
gotConnOpen bool
206+
)
207+
SetOnEvent(func(eventType int, jsonPayload string) {
208+
if eventType != EventConnections {
209+
return
210+
}
211+
connMu.Lock()
212+
defer connMu.Unlock()
213+
connEvents = append(connEvents, jsonPayload)
214+
if !gotConnOpen {
215+
var msg struct {
216+
Reset bool `json:"reset"`
217+
Items []struct {
218+
Type int `json:"type"`
219+
ID string `json:"id"`
220+
} `json:"items"`
221+
}
222+
if json.Unmarshal([]byte(jsonPayload), &msg) == nil {
223+
for _, item := range msg.Items {
224+
if item.Type == 0 {
225+
gotConnOpen = true
226+
break
227+
}
228+
}
229+
}
230+
}
231+
})
232+
233+
if err := Start(configPath); err != nil {
234+
t.Fatalf("Start: %v", err)
235+
}
236+
defer Stop()
237+
238+
proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort)
239+
if !waitForListen(t, proxyAddr, 10*time.Second) {
240+
t.Fatalf("proxy %s not listening after 10s", proxyAddr)
241+
}
242+
t.Logf("proxy listening on %s", proxyAddr)
243+
244+
time.Sleep(2 * time.Second)
245+
246+
// Dial through SOCKS5
247+
socksDialer, err := proxy.SOCKS5("tcp", proxyAddr, nil, &net.Dialer{Timeout: 10 * time.Second})
248+
if err != nil {
249+
t.Fatalf("create SOCKS5 dialer: %v", err)
250+
}
251+
252+
transport := &http.Transport{
253+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
254+
return socksDialer.Dial(network, addr)
255+
},
256+
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
257+
TLSHandshakeTimeout: 10 * time.Second,
258+
ResponseHeaderTimeout: 15 * time.Second,
259+
}
260+
261+
client := &http.Client{
262+
Transport: transport,
263+
Timeout: 30 * time.Second,
264+
}
265+
266+
reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
267+
defer cancel()
268+
269+
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://www.google.com", nil)
270+
if err != nil {
271+
t.Fatalf("create request: %v", err)
272+
}
273+
req.Header.Set("User-Agent", "singcast-connectivity-test/1.0")
274+
275+
resp, err := client.Do(req)
276+
if err != nil {
277+
t.Fatalf("GET google.com through SOCKS5 proxy: %v", err)
278+
}
279+
defer resp.Body.Close()
280+
281+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
282+
t.Logf("Status: %d", resp.StatusCode)
283+
t.Logf("Body (first 200 bytes): %.200s", string(body))
284+
285+
if resp.StatusCode != http.StatusOK {
286+
t.Errorf("expected status 200, got %d", resp.StatusCode)
287+
}
288+
289+
connMu.Lock()
290+
events := connEvents
291+
sawOpen := gotConnOpen
292+
connMu.Unlock()
293+
294+
if !sawOpen {
295+
t.Error("no connection open event received — traffic may not have gone through singcast")
296+
} else {
297+
t.Logf("confirmed: received %d connection events, traffic went through SOCKS5 proxy", len(events))
298+
}
299+
}
300+
301+
// TestConnectivity_HTTPS verifies that an HTTPS site is reachable through the
302+
// mixed proxy inbound using HTTP CONNECT (the same as TestConnectivity_Google
303+
// but targets a different HTTPS host to exercise TLS negotiation).
304+
func TestConnectivity_HTTPS(t *testing.T) {
305+
if _, err := os.Stat(realConfigPath); err != nil {
306+
t.Skipf("real config not found: %s", realConfigPath)
307+
}
308+
309+
data, err := os.ReadFile(realConfigPath)
310+
if err != nil {
311+
t.Fatalf("read config: %v", err)
312+
}
313+
314+
jsonContent, warns, err := translator.Translate(data)
315+
if err != nil {
316+
t.Fatalf("translate config: %v", err)
317+
}
318+
for _, w := range warns {
319+
t.Logf("WARN: %s", w)
320+
}
321+
322+
mixedPort := extractMixedPort(t, jsonContent)
323+
if mixedPort == 0 {
324+
t.Fatal("no mixed inbound port found in config")
325+
}
326+
327+
tmpDir := t.TempDir()
328+
homeDir := filepath.Join(tmpDir, "home")
329+
if err := os.MkdirAll(homeDir, 0o700); err != nil {
330+
t.Fatal(err)
331+
}
332+
configPath := filepath.Join(tmpDir, "config.json")
333+
if err := os.WriteFile(configPath, []byte(jsonContent), 0o600); err != nil {
334+
t.Fatal(err)
335+
}
336+
337+
if err := Init(homeDir); err != nil {
338+
t.Fatalf("Init: %v", err)
339+
}
340+
defer Close()
341+
342+
var (
343+
connMu sync.Mutex
344+
connEvents []string
345+
gotConnOpen bool
346+
)
347+
SetOnEvent(func(eventType int, jsonPayload string) {
348+
if eventType != EventConnections {
349+
return
350+
}
351+
connMu.Lock()
352+
defer connMu.Unlock()
353+
connEvents = append(connEvents, jsonPayload)
354+
if !gotConnOpen {
355+
var msg struct {
356+
Reset bool `json:"reset"`
357+
Items []struct {
358+
Type int `json:"type"`
359+
ID string `json:"id"`
360+
} `json:"items"`
361+
}
362+
if json.Unmarshal([]byte(jsonPayload), &msg) == nil {
363+
for _, item := range msg.Items {
364+
if item.Type == 0 {
365+
gotConnOpen = true
366+
break
367+
}
368+
}
369+
}
370+
}
371+
})
372+
373+
if err := Start(configPath); err != nil {
374+
t.Fatalf("Start: %v", err)
375+
}
376+
defer Stop()
377+
378+
proxyAddr := fmt.Sprintf("127.0.0.1:%d", mixedPort)
379+
if !waitForListen(t, proxyAddr, 10*time.Second) {
380+
t.Fatalf("proxy %s not listening after 10s", proxyAddr)
381+
}
382+
t.Logf("proxy listening on %s", proxyAddr)
383+
384+
time.Sleep(2 * time.Second)
385+
386+
proxyURL, _ := url.Parse(fmt.Sprintf("http://%s", proxyAddr))
387+
transport := &http.Transport{
388+
Proxy: http.ProxyURL(proxyURL),
389+
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
390+
TLSHandshakeTimeout: 10 * time.Second,
391+
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
392+
ResponseHeaderTimeout: 15 * time.Second,
393+
}
394+
395+
client := &http.Client{
396+
Transport: transport,
397+
Timeout: 30 * time.Second,
398+
}
399+
400+
reqCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
401+
defer cancel()
402+
403+
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, "https://www.gstatic.com/generate_204", nil)
404+
if err != nil {
405+
t.Fatalf("create request: %v", err)
406+
}
407+
req.Header.Set("User-Agent", "singcast-connectivity-test/1.0")
408+
409+
resp, err := client.Do(req)
410+
if err != nil {
411+
t.Fatalf("GET gstatic.com through proxy: %v", err)
412+
}
413+
defer resp.Body.Close()
414+
415+
t.Logf("Status: %d", resp.StatusCode)
416+
417+
if resp.StatusCode != http.StatusNoContent {
418+
t.Errorf("expected status 204, got %d", resp.StatusCode)
419+
}
420+
421+
connMu.Lock()
422+
events := connEvents
423+
sawOpen := gotConnOpen
424+
connMu.Unlock()
425+
426+
if !sawOpen {
427+
t.Error("no connection open event received — traffic may not have gone through singcast")
428+
} else {
429+
t.Logf("confirmed: received %d connection events, HTTPS traffic went through proxy", len(events))
430+
}
431+
}
432+
160433
// extractMixedPort reads the mixed inbound listen_port from sing-box JSON config.
161434
func extractMixedPort(t *testing.T, jsonStr string) uint16 {
162435
t.Helper()

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/urfave/cli/v3 v3.8.0
88
go.yaml.in/yaml/v3 v3.0.4
99
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
10+
golang.org/x/net v0.53.0
1011
)
1112

1213
require (
@@ -140,7 +141,6 @@ require (
140141
golang.org/x/crypto v0.50.0 // indirect
141142
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
142143
golang.org/x/mod v0.35.0 // indirect
143-
golang.org/x/net v0.53.0 // indirect
144144
golang.org/x/oauth2 v0.34.0 // indirect
145145
golang.org/x/sync v0.20.0 // indirect
146146
golang.org/x/sys v0.43.0 // indirect

0 commit comments

Comments
 (0)