diff --git a/x/smart/integrationtest/integration_test.go b/x/smart/integrationtest/integration_test.go index 0894ab3e..179eedc8 100644 --- a/x/smart/integrationtest/integration_test.go +++ b/x/smart/integrationtest/integration_test.go @@ -12,31 +12,84 @@ // See the License for the specific language governing permissions and // limitations under the License. -//go:build nettest -// +build nettest - package integrationtest import ( "bytes" "context" + "errors" + "fmt" "log" + "net" + "net/http/httptest" "runtime" "strings" "testing" "time" - "golang.getoutline.org/sdk/x/configurl" + "golang.getoutline.org/sdk/transport" "golang.getoutline.org/sdk/x/smart" "github.com/stretchr/testify/require" ) +// mockStreamDialer intercepts stream connections made by StrategyFinder. +// It redirects connection attempts for specific badssl.com endpoints to local mock servers, +// and fails all other connections to simulate a completely broken network environment. +type mockStreamDialer struct { + base transport.StreamDialer + untrustedHTTPSServerAddr string // Address of the local mock HTTPS server representing mitm-software.badssl.com + captivePortalServerAddr string // Address of the local mock TCP server representing captive-portal.badssl.com:443 +} + +func (d *mockStreamDialer) DialStream(ctx context.Context, addr string) (transport.StreamConn, error) { + if addr == "mitm-software.badssl.com:443" { + return d.base.DialStream(ctx, d.untrustedHTTPSServerAddr) + } + if addr == "captive-portal.badssl.com:443" { + return d.base.DialStream(ctx, d.captivePortalServerAddr) + } + return nil, errors.New("mock network: no route to host") +} + +// errorPacketDialer simulates UDP dial failures by immediately returning an error. +// This mocks broken DNS-over-UDP resolvers (like china.cn, ns1.tic.ir, tmcell.tm). +type errorPacketDialer struct{} + +func (d *errorPacketDialer) DialPacket(ctx context.Context, addr string) (net.Conn, error) { + return nil, errors.New("dial DNS resolver failed") +} + +// startMockCaptivePortalTCPServer starts a local TCP server that accepts and immediately closes connections. +// This is used to mock a captive portal that interrupts TLS handshakes. +func startMockCaptivePortalTCPServer(t *testing.T) string { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + t.Cleanup(func() { + listener.Close() + }) + return listener.Addr().String() +} + func Test_Integration_NewDialer_BrokenConfig(t *testing.T) { if runtime.GOOS == "android" { // See https://golang.getoutline.org/sdk/issues/504 t.Skip("Skip Smart Dialer integration test on Android until storage is made compatible with android emulator testing") } + ts := httptest.NewTLSServer(nil) + t.Cleanup(ts.Close) + + captivePortalAddr := startMockCaptivePortalTCPServer(t) + configBytes := []byte(` dns: # We get censored DNS responses when we send queries to an IP in China. @@ -61,57 +114,50 @@ tls: fallback: # Nonexistent Outline Server - ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTprSzdEdHQ0MkJLOE9hRjBKYjdpWGFK@1.2.3.4:9999/?outline=1 - # TODO(#513): Psiphon disabled: psiphon-tls is incompatible with Go 1.25. - # - psiphon: { - # "PropagationChannelId":"ID1", - # "SponsorId":"ID2", - # "DisableLocalSocksProxy" : true, - # "DisableLocalHTTPProxy" : true, - # "EstablishTunnelTimeoutSeconds": 1, - # } + - error: { + "error": "failed to start dialer" + } # Nonexistant local socks5 proxy - socks5://192.168.1.10:1080 `) testDomains := []string{"www.example.com", "example.com"} - transportType := "" logBuffer := new(bytes.Buffer) logger := log.New(logBuffer, "", log.LstdFlags) - providers := configurl.NewDefaultProviders() - packetDialer, err := providers.NewPacketDialer(context.Background(), transportType) - if err != nil { - require.NoError(t, err) - } - streamDialer, err := providers.NewStreamDialer(context.Background(), transportType) - if err != nil { - require.NoError(t, err) - } - finder := smart.StrategyFinder{ LogWriter: logger.Writer(), TestTimeout: 5 * time.Second, - StreamDialer: streamDialer, - PacketDialer: packetDialer, + StreamDialer: &mockStreamDialer{ + base: &transport.TCPDialer{}, + untrustedHTTPSServerAddr: ts.Listener.Addr().String(), + captivePortalServerAddr: captivePortalAddr, + }, + PacketDialer: &errorPacketDialer{}, } - // TODO(#513): Psiphon disabled: psiphon-tls is incompatible with Go 1.25. - // finder.RegisterFallbackParser("psiphon", psiphon.ParseConfig) + finder.RegisterFallbackParser("error", func(ctx context.Context, config smart.YAMLNode) (transport.StreamDialer, string, error) { + m, ok := config.(map[string]any) + if !ok { + return nil, "", fmt.Errorf("invalid config of type %T", config) + } + errStr, _ := m["error"].(string) + return nil, "signature_placeholder", errors.New(errStr) + }) - _, err = finder.NewDialer(context.Background(), testDomains, configBytes) + _, err := finder.NewDialer(context.Background(), testDomains, configBytes) require.Error(t, err) require.Contains(t, err.Error(), "could not find a working fallback: all tests failed") // Check the content of the log writer. - // Different systems have different network error messages, - // so we only check the broad strokes. + // Different systems have different network error messages, so we only check the broad strokes. expectedLogs := []string{ "request for A query failed: dial DNS resolver failed:", `request for A query failed: receive DNS message failed: failed to get HTTP response: Post "https://mitm-software.badssl.com:443/dns-query": tls:`, "🏃 running test: 'ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTprSzdEdHQ0MkJLOE9hRjBKYjdpWGFK@1.2.3.4:9999/?outline=1'", - // TODO(#513): Psiphon disabled: psiphon-tls is incompatible with Go 1.25. - // "❌ Failed to create fallback[1]: [{psiphon: {DisableLocalHTTPProxy: true, DisableLocalSocksProxy: true, Establish…]: failed to start psiphon dialer: clientlib: tunnel establishment timeout", + "❌ Failed to create fallback[1]:", + "failed to start dialer", "🏃 running test: 'socks5://192.168.1.10:1080' (domain: www.example.com.)", } logContent := logBuffer.String()