@@ -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.
161434func extractMixedPort (t * testing.T , jsonStr string ) uint16 {
162435 t .Helper ()
0 commit comments