Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/en/native-benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
49 changes: 49 additions & 0 deletions docs/ru/native-benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
8 changes: 6 additions & 2 deletions native/mobile/https_jump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
25 changes: 19 additions & 6 deletions native/mobile/masque.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package mobile

import (
"bytes"
"context"
"encoding/base64"
"errors"
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
77 changes: 77 additions & 0 deletions native/mobile/masque_regression_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
8 changes: 4 additions & 4 deletions native/mobile/masque_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading