diff --git a/docs/en/native-benchmarks.md b/docs/en/native-benchmarks.md index f4ddede..2e907b5 100644 --- a/docs/en/native-benchmarks.md +++ b/docs/en/native-benchmarks.md @@ -76,4 +76,53 @@ traffic pattern. Measure throughput, latency, CPU, memory and ownership-query fr strict disabled, standard and advanced. These host microbenchmarks identify Go hot spots; they do not predict that device result. + +## Encrypted transport round trips + +Run these in order to inspect MASQUE, HTTPS and SSH separately: + +```sh +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportMASQUE' output:test-results/native/masque +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportHTTPS' output:test-results/native/https +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportSSH' output:test-results/native/ssh +``` + +These opt-in benchmarks open only loopback listeners and use the production dialers with +local echo peers. No Docker, Internet access, personal proxy or supplied credentials is needed. +They are outside the default CPU-only suite and required CI timing checks. + +| Transport | Workload | +| --- | --- | +| MASQUE | Warm QUIC streams at 64/1400/32768 bytes; CONNECT-UDP at 64/1200 bytes, with a fixed deadline or one renewed per exchange. | +| HTTPS | Warm CONNECT streams over HTTP/1.1, HTTP/2 and preferred HTTP/3 at 64/1400/32768 bytes. The benchmark verifies selection instead of silently measuring a fallback. | +| SSH | Warm encrypted direct-tcpip channel at 64/1400/32768 bytes, OpenSSH-style client profile, password authentication and a pinned ephemeral host key. The local peer is x/crypto/ssh, not OpenSSH. | + +One operation is a sequential request/echo round trip. `MB/s` counts both directions. +Setup, certificate/key generation, authentication and the initial tunnel/channel open are outside +the timer, as is one warm-up exchange. Payload contents are checked after the loop; each exchange +checks its length/errors. SSH channels do not implement net.Conn deadlines; a watchdog closes +stalled stream benchmarks. This is not a test of SSH deadline support. + +CPU and heap profiles cover **both client and echo server in one process**, including runtime, +crypto and library allocations. Per-operation allocation counts also include both endpoints. +Do not attribute server allocations to Android or compare these timings directly with the +in-memory `Core*` suite. The echo peers do not forward to a separate origin. Server-side request +validation, credentials and key checks remain active; TLS peers use test-only self-signed trust +exceptions. Profiles retain their normal encryption and TLS/SSH fingerprints. + +For example, profile one narrow case separately: + +```sh +bundle exec fastlane android native_benchmarks \ + 'bench:BenchmarkTransportMASQUE/datagram/bytes=1200/refresh-deadline=false$' \ + count:1 benchtime:5s profile:true output:test-results/native/masque-profile +``` + +Compare raw samples with benchstat as above. Unchanged stream scenarios are useful controls for +host drift: a timing change shared by controls is not evidence of an optimization's effect. +These are warm single-flow loopback measurements, without packet loss, congestion, radio changes, +reconnect storms, jump chains, sustained rekey/rotation workloads, TUN or JNI. Real GOST/OpenSSH +interoperability remains the separate `native_integration` lane; Android integration remains the +device lanes. These results do not establish which transport is fastest on a phone. + [Русская версия](../ru/native-benchmarks.md) diff --git a/docs/ru/native-benchmarks.md b/docs/ru/native-benchmarks.md index 9825ce3..5eca048 100644 --- a/docs/ru/native-benchmarks.md +++ b/docs/ru/native-benchmarks.md @@ -77,4 +77,53 @@ go tool pprof -top -alloc_space test-results/native/profile/mobile.test test-res задержку, CPU, память и частоту запросов владельца в выключенном, стандартном и расширенном режимах. Эти микробенчмарки находят узкие места Go, но не предсказывают результат устройства. + +## Передача через зашифрованные транспорты + +Запускайте по порядку, чтобы исследовать MASQUE, HTTPS и SSH отдельно: + +```sh +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportMASQUE' output:test-results/native/masque +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportHTTPS' output:test-results/native/https +bundle exec fastlane android native_benchmarks 'bench:BenchmarkTransportSSH' output:test-results/native/ssh +``` + +Эти дополнительные бенчмарки открывают только loopback-сокеты и используют рабочие dialer-ы +с локальными echo-серверами. Docker, Интернет, личный прокси и предоставленные учётные данные +не нужны. Набор не входит в основные CPU-бенчмарки и обязательные CI-пороги времени. + +| Транспорт | Нагрузка | +| --- | --- | +| MASQUE | Установленные QUIC-потоки, 64/1400/32768 байт; CONNECT-UDP, 64/1200 байт, с постоянным deadline или его обновлением на каждый обмен. | +| HTTPS | Установленные CONNECT-потоки через HTTP/1.1, HTTP/2 и предпочитаемый HTTP/3, 64/1400/32768 байт. Бенчмарк проверяет выбор протокола, не подменяя его незаметным fallback. | +| SSH | Установленный зашифрованный direct-tcpip канал, 64/1400/32768 байт, OpenSSH-подобный клиентский профиль, пароль и закреплённый временный ключ сервера. Локальный сервер использует x/crypto/ssh, не OpenSSH. | + +Операция — последовательный запрос и echo-ответ; `MB/s` учитывает оба направления. +Подготовка, создание сертификата/ключа, аутентификация и первоначальное открытие туннеля/канала +не входят в замер, как и один прогревочный обмен. Содержимое проверяется после цикла; +длина и ошибки — на каждом обмене. SSH-каналы не реализуют net.Conn deadlines: зависший +потоковый бенчмарк закрывает watchdog. Поддержка SSH deadlines здесь не проверяется. + +CPU/heap-профили охватывают **клиент и echo-сервер в одном процессе**, включая runtime, +криптографию и библиотеки. Аллокации на операцию также учитывают обе стороны. +Не приписывайте серверные выделения Android и не сравнивайте эти времена напрямую +с `Core*` в памяти. Echo-серверы не пересылают данные на отдельный origin. Проверки запросов, +учётных данных и ключей активны; для тестовых TLS-сертификатов заданы локальные исключения +доверия. Шифрование и TLS/SSH-отпечатки остаются обычными для выбранного профиля. + +Например, отдельный профиль одного сценария: + +```sh +bundle exec fastlane android native_benchmarks \ + 'bench:BenchmarkTransportMASQUE/datagram/bytes=1200/refresh-deadline=false$' \ + count:1 benchtime:5s profile:true output:test-results/native/masque-profile +``` + +Сравнивайте исходные сэмплы через benchstat, как описано выше. Неизменённые потоковые сценарии +служат контролем дрейфа машины: общее для них изменение времени не доказывает эффект оптимизации. +Это замеры одного прогретого потока на loopback, без потерь пакетов, перегрузки, радиосвязи, +массовых переподключений, jump-цепочек, длительной нагрузки на rekey/ротацию, TUN и JNI. +Совместимость с настоящими GOST/OpenSSH проверяется отдельной командой `native_integration`, +а Android — device lanes. Эти результаты не определяют самый быстрый транспорт на телефоне. + [English version](../en/native-benchmarks.md) diff --git a/native/mobile/https_jump_test.go b/native/mobile/https_jump_test.go index d97b9ce..2b72718 100644 --- a/native/mobile/https_jump_test.go +++ b/native/mobile/https_jump_test.go @@ -26,7 +26,7 @@ func (p *jumpTestProtector) Protect(int) bool { p.calls.Add(1); return true } // Each server accepts exactly one CONNECT authority and credential pair. The // jump maps a deliberately unresolvable destination hostname to a local server. -func jumpTestProxy(t *testing.T, h2 bool, authority, credentials, forward string, reject bool) *httptest.Server { +func jumpTestProxy(t testing.TB, h2 bool, authority, credentials, forward string, reject bool, idleTimeout ...time.Duration) *httptest.Server { t.Helper() server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodConnect || r.Host != authority { @@ -61,7 +61,11 @@ func jumpTestProxy(t *testing.T, h2 bool, authority, credentials, forward string return } defer conn.Close() - _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + timeout := 5 * time.Second + if len(idleTimeout) > 0 { + timeout = idleTimeout[0] + } + _ = conn.SetDeadline(time.Now().Add(timeout)) _, _ = buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n") _ = buffer.Flush() if upstream == nil { diff --git a/native/mobile/masque.go b/native/mobile/masque.go index 1901e04..f154be9 100644 --- a/native/mobile/masque.go +++ b/native/mobile/masque.go @@ -1,7 +1,6 @@ package mobile import ( - "bytes" "context" "encoding/base64" "errors" @@ -667,12 +666,11 @@ func (p *masquePacketConn) receive() { } return } - reader := bytes.NewReader(data) - id, err := quicvarint.Read(reader) + id, consumed, err := quicvarint.Parse(data) if err != nil || id != 0 { continue } - payload := data[len(data)-reader.Len():] + payload := data[consumed:] select { case p.packets <- dnsReply{payload: payload}: case <-p.Context().Done(): @@ -701,8 +699,23 @@ func (p *masquePacketConn) ReadFrom(b []byte) (int, net.Addr, error) { return n, p.remote, nil } } + +// Avoid formatting valid UDP endpoints on every packet. Other address types +// retain the existing textual comparison, including custom net.Addr wrappers. +func equalUDPPeers(a, b net.Addr) bool { + if a == nil || b == nil { + return false + } + x, xok := a.(*net.UDPAddr) + y, yok := b.(*net.UDPAddr) + if xok && yok && x != nil && y != nil && x.IP.To16() != nil && y.IP.To16() != nil { + return x.Port == y.Port && x.Zone == y.Zone && x.IP.Equal(y.IP) + } + return a.String() == b.String() +} + func (p *masquePacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { - if addr == nil || addr.String() != p.remote.String() { + if !equalUDPPeers(addr, p.remote) { return 0, errors.New("MASQUE UDP target cannot change") } select { @@ -772,7 +785,7 @@ func (p *masqueDirectPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { return n, p.RemoteAddr(), err } func (p *masqueDirectPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) { - if addr == nil || addr.String() != p.RemoteAddr().String() { + if !equalUDPPeers(addr, p.RemoteAddr()) { return 0, errors.New("UDP target cannot change") } return p.Write(b) diff --git a/native/mobile/masque_regression_test.go b/native/mobile/masque_regression_test.go index e60f920..d14b2be 100644 --- a/native/mobile/masque_regression_test.go +++ b/native/mobile/masque_regression_test.go @@ -485,3 +485,80 @@ func TestQUICStreamDiagnosticsIdentifySharedFailedSession(t *testing.T) { t.Fatalf("missing shared QUIC session identity: %s", logs.text()) } } + +func TestUDPPeerComparisonPreservesAddressPolicy(t *testing.T) { + addresses := []net.Addr{ + &net.UDPAddr{IP: net.IP{192, 0, 2, 1}, Port: 443}, + &net.UDPAddr{IP: net.ParseIP("::ffff:192.0.2.1"), Port: 443}, + &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 443}, + &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 444}, + &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 443}, + &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: "wlan0", Port: 443}, + &net.UDPAddr{IP: net.ParseIP("fe80::1"), Zone: "wlan1", Port: 443}, + &net.UDPAddr{Port: 443}, &net.UDPAddr{IP: net.IP{1, 2}, Port: 443}, + &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 443}, + } + for _, a := range addresses { + for _, b := range addresses { + if equalUDPPeers(a, b) != (a.String() == b.String()) { + t.Fatalf("peer policy changed for %v / %v", a, b) + } + } + } + if equalUDPPeers(nil, addresses[0]) || equalUDPPeers(addresses[0], nil) { + t.Fatal("nil peer accepted") + } + if n := testing.AllocsPerRun(1000, func() { + if !equalUDPPeers(addresses[0], addresses[1]) { + panic("mapped peer rejected") + } + }); n != 0 { + t.Fatalf("peer check allocates: %v", n) + } +} + +func TestMASQUEContextPrefixFiltering(t *testing.T) { + zero := [][]byte{{0}, {0x40, 0}, {0x80, 0, 0, 0}, {0xc0, 0, 0, 0, 0, 0, 0, 0}} + fixture := newMasqueFixture(t, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Capsule-Protocol", "?1") + w.WriteHeader(200) + stream := w.(http3.HTTPStreamer).HTTPStream() + defer stream.Close() + for { + data, err := stream.ReceiveDatagram(r.Context()) + if err != nil { + return + } + if len(data) != 2 || int(data[1]) >= len(zero) { + t.Error("unexpected request") + return + } + for _, bad := range [][]byte{{}, {1}, {0x40}, {0x80, 0}, {0xc0, 0, 0}} { + if stream.SendDatagram(bad) != nil { + return + } + } + if stream.SendDatagram(append(append([]byte(nil), zero[data[1]]...), data[1])) != nil { + return + } + } + })) + d := &masqueDialer{config: fixture.config, protector: &jumpTestProtector{}} + defer d.Close() + conn, err := d.DialUDP(&M.Metadata{DstIP: netip.MustParseAddr("192.0.2.1"), DstPort: 443}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + peer := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 443} + for i := range zero { + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := conn.WriteTo([]byte{byte(i)}, peer); err != nil { + t.Fatal(err) + } + var buf [8]byte + if n, _, err := conn.ReadFrom(buf[:]); err != nil || n != 1 || buf[0] != byte(i) { + t.Fatalf("context encoding %d: n=%d err=%v", i, n, err) + } + } +} diff --git a/native/mobile/masque_test.go b/native/mobile/masque_test.go index 24bca5c..dfd626d 100644 --- a/native/mobile/masque_test.go +++ b/native/mobile/masque_test.go @@ -29,17 +29,17 @@ type masqueFixture struct { serving <-chan error } -func masqueTestServer(t *testing.T) config { return newMasqueFixture(t, nil).config } +func masqueTestServer(t testing.TB) config { return newMasqueFixture(t, nil).config } -func newMasqueFixture(t *testing.T, wrap func(net.PacketConn) net.PacketConn, handler ...http.Handler) masqueFixture { +func newMasqueFixture(t testing.TB, wrap func(net.PacketConn) net.PacketConn, handler ...http.Handler) masqueFixture { return newMasqueFixtureWithDatagrams(t, wrap, true, handler...) } -func newMasqueFixtureWithDatagrams(t *testing.T, wrap func(net.PacketConn) net.PacketConn, datagrams bool, handler ...http.Handler) masqueFixture { +func newMasqueFixtureWithDatagrams(t testing.TB, wrap func(net.PacketConn) net.PacketConn, datagrams bool, handler ...http.Handler) masqueFixture { return newMasqueFixtureWithCID(t, wrap, datagrams, 4, handler...) } -func newMasqueFixtureWithCID(t *testing.T, wrap func(net.PacketConn) net.PacketConn, datagrams bool, cidLength int, handler ...http.Handler) masqueFixture { +func newMasqueFixtureWithCID(t testing.TB, wrap func(net.PacketConn) net.PacketConn, datagrams bool, cidLength int, handler ...http.Handler) masqueFixture { t.Helper() certificateServer := httptest.NewTLSServer(nil) certificate := certificateServer.TLS.Certificates[0] diff --git a/native/mobile/transport_benchmark_test.go b/native/mobile/transport_benchmark_test.go new file mode 100644 index 0000000..96839f8 --- /dev/null +++ b/native/mobile/transport_benchmark_test.go @@ -0,0 +1,301 @@ +package mobile + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "errors" + "fmt" + "golang.org/x/crypto/ssh" + "io" + "net" + "net/netip" + "strconv" + "sync" + "testing" + "time" + + M "github.com/xjasonlyu/tun2socks/v2/metadata" +) + +// Loopback round trips include the production dialer, encryption and local peer. +// Reported bytes include request + response. They exclude TUN, JNI and Android. +func benchmarkStreamEcho(b *testing.B, conn net.Conn, size int) { + b.Helper() + b.Cleanup(func() { _ = conn.Close() }) + // SSH channels do not implement deadlines; a watchdog bounds all variants. + watchdog := time.AfterFunc(time.Minute, func() { _ = conn.Close() }) + b.Cleanup(func() { watchdog.Stop() }) + _ = conn.SetDeadline(time.Now().Add(time.Minute)) + payload, reply := bytes.Repeat([]byte{0x5a}, size), make([]byte, size) + // Warm the connection before starting the timer. + exchange := func() { + if n, err := conn.Write(payload); err != nil || n != size { + b.Fatalf("write: n=%d err=%v", n, err) + } + if _, err := io.ReadFull(conn, reply); err != nil { + b.Fatal(err) + } + } + exchange() + b.SetBytes(int64(2 * size)) + b.ReportAllocs() + for b.Loop() { + exchange() + } + if !bytes.Equal(payload, reply) { + b.Fatal("echo payload changed") + } +} + +func BenchmarkTransportMASQUE(b *testing.B) { + for _, size := range []int{64, 1400, 32768} { + b.Run(fmt.Sprintf("stream/bytes=%d", size), func(b *testing.B) { + fixture := newMasqueFixture(b, nil) + d := &masqueDialer{config: fixture.config, protector: &jumpTestProtector{}, stats: &connectionStats{}} + b.Cleanup(func() { _ = d.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, err := d.connectTarget(ctx, "origin.test:443") + if err != nil { + b.Fatal(err) + } + benchmarkStreamEcho(b, conn, size) + }) + } + for _, size := range []int{64, 1200} { + for _, refreshDeadline := range []bool{false, true} { + b.Run(fmt.Sprintf("datagram/bytes=%d/refresh-deadline=%t", size, refreshDeadline), func(b *testing.B) { + fixture := newMasqueFixture(b, nil) + d := &masqueDialer{config: fixture.config, protector: &jumpTestProtector{}, stats: &connectionStats{}} + b.Cleanup(func() { _ = d.Close() }) + conn, err := d.DialUDP(&M.Metadata{DstIP: netip.MustParseAddr("192.0.2.1"), DstPort: 443}) + if err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = conn.Close() }) + peer := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 1), Port: 443} + payload, reply := bytes.Repeat([]byte{0x5a}, size), make([]byte, size) + expectedPeer := conn.(*masquePacketConn).remote + _ = conn.SetDeadline(time.Now().Add(time.Minute)) + exchange := func() { + if refreshDeadline { + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + } + if n, err := conn.WriteTo(payload, peer); err != nil || n != size { + b.Fatalf("write: n=%d err=%v", n, err) + } + if n, from, err := conn.ReadFrom(reply); err != nil || n != size || from != expectedPeer { + b.Fatalf("read: n=%d from=%v err=%v", n, from, err) + } + } + exchange() + b.SetBytes(int64(2 * size)) + b.ReportAllocs() + for b.Loop() { + exchange() + } + if !bytes.Equal(payload, reply) { + b.Fatal("echo payload changed") + } + }) + } + } +} + +func BenchmarkTransportHTTPS(b *testing.B) { + for _, protocol := range []string{"http1", "http2", "http3"} { + for _, size := range []int{64, 1400, 32768} { + b.Run(fmt.Sprintf("%s/bytes=%d", protocol, size), func(b *testing.B) { + var cfg config + if protocol == "http3" { + cfg = newMasqueFixture(b, nil).config + cfg.Type = "HTTPS" + cfg.PreferHTTP3 = true + } else { + server := jumpTestProxy(b, protocol == "http2", "origin.test:443", "user:password", "", false, time.Minute) + host, port, _ := net.SplitHostPort(server.Listener.Addr().String()) + number, _ := strconv.Atoi(port) + cfg = config{Type: "HTTPS", Host: host, DialHost: host, Port: number, Username: "user", Password: "password", Profile: "CHROME_ANDROID", AllowInvalidProxyCertificate: true} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stats := &connectionStats{sockets: make(map[*measuredTCPConn]struct{})} + var conn net.Conn + if protocol == "http3" { + d, err := preferredHTTP3(ctx, cfg, &jumpTestProtector{}, nil, stats) + if err != nil || d == nil { + b.Fatalf("HTTP/3 selection failed: %v", err) + } + b.Cleanup(func() { _ = d.Close() }) + conn, err = d.connectTarget(ctx, "origin.test:443") + if err != nil { + b.Fatal(err) + } + } else { + d := &httpsConnectDialer{config: cfg, protector: &jumpTestProtector{}, stats: stats} + b.Cleanup(func() { _ = d.Close() }) + var err error + conn, err = d.connectTarget(ctx, "origin.test:443") + if err != nil { + b.Fatal(err) + } + d.cacheMu.Lock() + h2 := d.h2Session != nil + d.cacheMu.Unlock() + if h2 != (protocol == "http2") { + b.Fatal("unexpected negotiated HTTP protocol") + } + } + benchmarkStreamEcho(b, conn, size) + }) + } + } +} + +// Real encrypted direct-tcpip channels with disposable credentials and pinned host key. +// The local SSH peer echoes inside each channel, without dialing any origin. +func transportSSHFixture(t testing.TB) config { + t.Helper() + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(private) + if err != nil { + t.Fatal(err) + } + serverConfig := &ssh.ServerConfig{PasswordCallback: func(c ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) { + if c.User() != "user" || string(password) != "password" { + return nil, errors.New("invalid fixture credentials") + } + return nil, nil + }} + serverConfig.AddHostKey(signer) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + var mu sync.Mutex + connections := make(map[net.Conn]struct{}) + var workers sync.WaitGroup + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + for { + raw, err := listener.Accept() + if err != nil { + return + } + mu.Lock() + connections[raw] = struct{}{} + mu.Unlock() + workers.Add(1) + go func() { + defer workers.Done() + defer func() { _ = raw.Close(); mu.Lock(); delete(connections, raw); mu.Unlock() }() + _ = raw.SetDeadline(time.Now().Add(10 * time.Second)) + server, channels, requests, err := ssh.NewServerConn(raw, serverConfig) + if err != nil { + return + } + defer server.Close() + _ = raw.SetDeadline(time.Time{}) + go ssh.DiscardRequests(requests) + for request := range channels { + var target struct { + Host string + Port uint32 + Origin string + OriginPort uint32 + } + if request.ChannelType() != "direct-tcpip" || ssh.Unmarshal(request.ExtraData(), &target) != nil || target.Host != "origin.test" || target.Port != 443 { + _ = request.Reject(ssh.Prohibited, "fixture target denied") + continue + } + channel, requests, err := request.Accept() + if err != nil { + continue + } + go ssh.DiscardRequests(requests) + workers.Add(1) + go func() { defer workers.Done(); defer channel.Close(); _, _ = io.Copy(channel, channel) }() + } + }() + } + }() + t.Cleanup(func() { + _ = listener.Close() + <-acceptDone + mu.Lock() + for raw := range connections { + _ = raw.Close() + } + mu.Unlock() + workers.Wait() + }) + host, port, _ := net.SplitHostPort(listener.Addr().String()) + number, _ := strconv.Atoi(port) + return config{Type: "SSH", Host: host, DialHost: host, Port: number, Username: "user", Password: "password", TrustedHostKey: ssh.FingerprintSHA256(signer.PublicKey()), SSHProfile: "OPENSSH_TERMUX", SSHAuthMode: "PASSWORD_ONLY", SSHMaxChannels: 32} +} + +func BenchmarkTransportSSH(b *testing.B) { + for _, size := range []int{64, 1400, 32768} { + b.Run(fmt.Sprintf("stream/bytes=%d", size), func(b *testing.B) { + cfg := transportSSHFixture(b) + d := &sshDialer{config: cfg, protector: &jumpTestProtector{}, stats: &connectionStats{sockets: make(map[*measuredTCPConn]struct{})}} + b.Cleanup(func() { _ = d.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + conn, err := d.connectTarget(ctx, "origin.test:443") + if err != nil { + b.Fatal(err) + } + benchmarkStreamEcho(b, conn, size) + }) + } +} + +func TestTransportSSHFixtureRejectsWrongHostKeyAndTarget(t *testing.T) { + for _, failure := range []string{"host-key", "target", "password", "none"} { + t.Run(failure, func(t *testing.T) { + cfg := transportSSHFixture(t) + if failure == "host-key" { + cfg.TrustedHostKey = "incorrect" + } + if failure == "password" { + cfg.Password = "incorrect" + } + d := &sshDialer{config: cfg, protector: &jumpTestProtector{}} + defer d.Close() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + target := "origin.test:443" + if failure == "target" { + target = "untrusted.test:443" + } + conn, err := d.connectTarget(ctx, target) + if failure != "none" { + if err == nil { + _ = conn.Close() + t.Fatal("untrusted connection accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + defer conn.Close() + watchdog := time.AfterFunc(3*time.Second, func() { _ = conn.Close() }) + defer watchdog.Stop() + if _, err := conn.Write([]byte("echo")); err != nil { + t.Fatal(err) + } + var reply [4]byte + if _, err := io.ReadFull(conn, reply[:]); err != nil || string(reply[:]) != "echo" { + t.Fatalf("echo: %v", err) + } + }) + } +}