Skip to content

SolverNA/mpq-brutal

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MPQ-Brutal

Bonding multiple QUIC links under the Brutal congestion controller — sum the bandwidth of several physical channels and punch through packet loss without collapsing throughput.

🇬🇧 English | 🇷🇺 Русский


Overview

MPQ-Brutal crosses two ideas:

  • Multipath bonding — aggregate N independent network channels (interfaces, ISPs, links) into a single logical stream with summed bandwidth.
  • Brutal — the congestion controller from Hysteria2: instead of reacting to loss, it holds a fixed target bandwidth and compensates for lost packets by forced re-sending. On lossy links Brutal does not "collapse" the way CUBIC/BBR do.

The result: several channels add up into one aggregate rate, per-channel loss is neutralized by Brutal on each path independently, and the system decides by itself how much to pull from each channel — a fast path gets more data, a slow one less, a dead one drops out.

A Session implements io.ReadWriteCloser: from the outside it is an ordinary reliable, ordered byte stream; inside, the transport is spread across several QUIC paths.

Key features

  • Aggregation of N QUIC paths into one bonded session (summed throughput).
  • Per-path Brutal CC — every path is its own QUIC connection with its own Brutal instance (internal/congestion/brutal) and its own UDP socket, so paths can be pinned to different local addresses / interfaces.
  • Per-path BBR-like estimator (core/estimator.go) — windowed minRTT / maxBandwidth filters plus a probe / drain / steady phase machine with hysteresis; it discovers each channel's real capacity on its own and feeds the target bps to Brutal, clamped to [MinBps, MaxBps].
  • Pull scheduler (core/scheduler.go) — auto-balancing via a pull model: a path worker fetches the next chunk when it is ready to send. A fast path frees up more often and pulls more chunks, so the split is proportional to real capacity with no explicit weight math.
  • App-level reliability — a per-session ACK layer (cumulative ACK + SACK) on top of the bonding. Unacked chunks are retransmitted on two triggers: instantly when a path dies (all its unacked chunks move to live paths), and by RTO (max(50ms, 2×RTT)) otherwise. Duplicate retransmits are dropped by the reorder buffer.
  • Fast-retransmit of a stuck head — SACK-driven early retransmit of a head-of-line hole onto the fastest live path, with anti-spam (dup-ACK threshold, RTT time-floor, per-ACK cap).
  • Adaptive reorder buffer (core/reasm.go) — the receive-window limit is recomputed each ACK cycle (≈ sum of live-path EstimatedBps × RTT spread), clamped to [MinReorderBytes, MaxReorderBytes] with a hard cap of 64 MB. Deadlock-safe: the seq == nextSeq chunk is always accepted, even over the limit. Applies backpressure otherwise.
  • Send-side flow control (rwnd) — the receiver advertises a receive window in every ACK; the sender keeps its unacked tail within it (startup window of 16 chunks before the first ACK).
  • Graceful half-close — a FIN frame + CloseWrite(): closing one direction does not tear down the reverse one; full close only after both directions drain.
  • Dynamic paths on the flySession.AddPath(ctx, localAddr) / Session.RemovePath(pathID) change the path set of a live session without interrupting transfer (the server attaches a new connection with the same sessionID as an extra path, not a new session). Respects MaxPaths; the last path cannot be removed; a self-dying path is auto-cleaned with no goroutine leaks.
  • TLS with SPKI pinning — SHA-256 of the server's SubjectPublicKeyInfo. The client trusts the server via -pin (public-key pinning, constant-time compare), -ca (PEM trust anchor) or explicit -insecure. Without one of these the client refuses to start.
  • QUIC keep-alive — keep-alive PING (15s) with a larger idle timeout (45s) so idle paths do not die on NAT / QUIC idle timeout.

How it works

                        ┌────────────────────────────────┐
   Write([]byte) ─────► │  Session (io.ReadWriteCloser)   │ ◄──── Read([]byte)
                        └───────────────┬─────────────────┘
                                        │  seq/payload chunks
                 ┌──────────────────────┴──────────────────────┐
                 │              pull scheduler                  │  auto-balance:
                 │   (a faster path pulls more chunks)          │  pull model
                 └───┬───────────────┬────────────────────┬────┘
                     │               │                    │
              ┌──────┴──────┐ ┌──────┴──────┐      ┌──────┴──────┐
              │   Path 0    │ │   Path 1    │  ...  │   Path N    │
              │ QUIC conn   │ │ QUIC conn   │       │ QUIC conn   │
              │ + Brutal CC │ │ + Brutal CC │       │ + Brutal CC │
              │ + estimator │ │ + estimator │       │ + estimator │
              └──────┬──────┘ └──────┬──────┘      └──────┬──────┘
                     └───────────────┴────────────────────┘
                                     │  incoming chunks (seq)
                        ┌────────────┴─────────────┐
                        │   reorder buffer (by seq) │ ► ordered Read
                        └───────────────────────────┘
  • Path — one QUIC connection with its own bidirectional data stream and its own Brutal CC instance. Its own UDP socket lets you bind paths to different local addresses / interfaces.
  • Estimator — tracks each path's capacity independently with windowed minRTT / maxBandwidth filters (goodput taken honestly from ACK-confirmed bytes per path) and a probe/drain/steady phase machine; sets Brutal's target bps and keeps it in [MinBps, MaxBps].
  • Pull scheduler — no explicit weights; the ready path worker pulls the next chunk from the shared queue. Brutal on the fast path sends a chunk sooner → the worker frees sooner → pulls the next one, so the fast path naturally carries proportionally more traffic. A dead path's unfinished chunk is picked up by a live neighbour.
  • Reliability + reorder — chunks arrive over different paths interleaved; the ACK layer (cumAck + SACK) drives retransmit and the reorder buffer restores order by seq before handing bytes to Read.

Why bonding over N QUIC connections, not "true" multipath-QUIC

There is no live draft-ietf-quic-multipath for Go: the historical mp-quic is dead and upstream quic-go never shipped multipath. So instead of one connection with several paths we bring up N ordinary QUIC connections and do the bonding one layer up.

Key point: true multipath-QUIC needs coupled congestion control (shared across paths to fairly split a common bottleneck). Brutal is a per-path fixed target bandwidth, which fits the "each channel on its own" scheme perfectly — nothing to couple, each path just holds its own rate and the scheduler does the aggregation. The usual "drawback" (no shared CC) is exactly what we want here.

Wire format (brief)

  • Handshake frame: magic="MPQB" + version(1) + sessionID(16 B) + pathID(1) + role(1: client/server). All paths of a session carry the same sessionID, which is how the server groups incoming connections into one bonding session.
  • Data frame: type=0x01 + seq(uint64) + len(uint32) + payload (seq is the session-wide chunk order number; payload ≤ 8 KiB).
  • Ack frame: type=0x02 + cumAck(uint64) + rwnd(uint32) + count(uint16) + SACK seq × count.
  • Fin frame: type=0x03 + finalSeq(uint64) (graceful write half-close).

Requirements

  • Go 1.25+ (see go.mod).
  • Core depends on the apernet/quic-go fork (the Hysteria2 QUIC fork). The CLIs themselves are pure stdlib.

Build

go build ./cmd/...      # builds mpqb-server and mpqb-client

Binaries come from:

  • cmd/mpqb-servermpqb-server
  • cmd/mpqb-clientmpqb-client

Usage

Server (mpqb-server)

Flag Default Purpose
-listen :4242 QUIC listen address host:port.
-target TCP destination to proxy bonded sessions to (e.g. 127.0.0.1:5201).
-bench false Benchmark mode: drain the session into io.Discard, print throughput.
-init-bps 50mbps Initial Brutal target bandwidth per path.
-min-bps 1mbps Lower bound of per-path auto-estimation.
-max-bps 1gbps Upper bound of per-path auto-estimation.
-cert PEM server cert (only together with -key); empty = self-signed on the fly.
-key PEM server key (only together with -cert).
-cert-out Save the generated self-signed cert to a PEM file (for -ca on client).

You must pass either -target (proxy) or -bench. At startup the server prints its SPKI pin (sha256/<hex> of the leaf cert's SubjectPublicKeyInfo) — hand it to the client via -pin.

# next to an iperf3 server (iperf3 -s on :5201)
mpqb-server -listen :4242 -target 127.0.0.1:5201 -init-bps 200mbps -max-bps 1gbps
# → prints: SPKI-пин сервера: sha256/<HEX>  (pass it to the client via -pin <HEX>)

Client (mpqb-client)

Flag Default Purpose
-server 127.0.0.1:4242 QUIC server address host:port.
-locals CSV of local path addresses (e.g. 127.0.0.1:0,127.0.0.1:0); each = one path; empty = single path.
-listen Local TCP address to tunnel; each TCP connection → one session.
-bench false Benchmark mode: push data into the session and print throughput.
-duration 10s Benchmark duration.
-init-bps 50mbps Initial Brutal target bandwidth per path.
-min-bps 1mbps Lower bound of per-path auto-estimation.
-max-bps 1gbps Upper bound of per-path auto-estimation.
-pin Expected server SPKI pin (SHA-256 hex, printed by the server) → enables pinning.
-ca PEM CA/cert to trust (alternative to -pin).
-servername SNI / verification name (defaults to the host from -server).
-insecure false Explicitly disable TLS verification (MITM-vulnerable; debug only).

You must pass either -listen (tunnel) or -bench. TLS is mandatory: the client requires exactly one of -pin, -ca, or -insecure and refuses to start otherwise (no silent insecure mode).

# tunnel: local TCP :5201 → QUIC server, two bonded paths, pinned cert
mpqb-client -server SERVER_IP:4242 -listen 127.0.0.1:5201 \
    -locals 127.0.0.1:0,127.0.0.1:0 \
    -pin <HEX> -init-bps 200mbps -max-bps 1gbps

# drive traffic through the tunnel
iperf3 -c 127.0.0.1 -p 5201

Bandwidth values (-init-bps / -min-bps / -max-bps) are human-readable: 500kbps, 50mbps, 1gbps. The suffix denotes bits per second (no suffix = bits/s too); internally everything is converted to bytes/s.

TLS modes:

  • -pin <hex> — pin the server's public key (SPKI SHA-256, constant-time compare). Best default: no shared CA needed, MITM-safe.
  • -ca <file.pem> — trust a CA/cert from a PEM file (pair with the server's -cert-out).
  • -insecure — no verification, prints a loud warning. Debugging only.

Benchmark

Built-in -bench mode measures goodput (application payload accepted via Write, without retransmits — honest, unlike wire bytes which loss inflates).

# terminal 1 — server in bench mode
mpqb-server -listen 127.0.0.1:4242 -bench -init-bps 200mbps -max-bps 1gbps

# terminal 2 — client pushes over 2 paths for 10s (insecure ok on loopback)
mpqb-client -server 127.0.0.1:4242 -bench -duration 10s \
    -locals 127.0.0.1:0,127.0.0.1:0 -insecure -init-bps 200mbps -max-bps 1gbps

Or all at once:

./scripts/bench-loopback.sh

Sample client output (2 paths, loopback) — both paths really carry traffic and the rate adds up:

бенч: сессия из 2 путь(ей) на 10s
итог: goodput 143.48 MiB за 10.00 c → 0.398 Гбит/с суммарно (полезные байты приложения, без ретрансмитов)
  путь 0: sent(провод)=61.95 MiB rtt=40µs est=0.165 Гбит/с alive=true
  путь 1: sent(провод)=81.54 MiB rtt=34µs est=0.255 Гбит/с alive=true

Testing on a lossy link

Brutal shines exactly on lossy channels. Emulate loss + delay on loopback with tc netem (needs sudo):

# add 5% loss + 20ms delay on lo
sudo tc qdisc add dev lo root netem loss 5% delay 20ms

# run the bench above — Brutal holds the target rate despite loss,
# whereas a loss-reactive CC would drop several-fold at 5% loss
./scripts/bench-loopback.sh

# revert
sudo tc qdisc del dev lo root

Convenience wrapper:

sudo ./scripts/netem-loss.sh on 5% 20ms   # enable
sudo ./scripts/netem-loss.sh off          # revert

CUBIC/BBR read loss as congestion and cut the window, so throughput collapses on a noisy link. Brutal holds the target rate and forces the lost bytes back through — on a defective channel that yields multiples of the effective throughput.

Configuration

Public core.Config (see core/mpq.go); zero value means "use default":

Field Default Meaning
RemoteAddr Server host:port (client / Dial).
LocalAddrs Per-path local bind addrs; empty = single default path.
ListenAddr Listen host:port (server / Listen).
TLSConfig TLS settings; server must set certificates. Empty ALPN → mpq-brutal.
InitialBps 50 Mbps Initial Brutal target bandwidth per path (bytes/s).
MinBps 1 Mbps Lower bound of per-path auto-estimation.
MaxBps 1 Gbps Upper bound of per-path auto-estimation.
ProbeInterval 200 ms Estimator tick period.
MaxPaths 8 Max paths per session (hard cap 256, since pathID is a byte).
ExpectedPaths 0 Server: paths to wait for before forming a session (0 = by timeout).
PathGatherTimeout 300 ms Server: how long to gather paths after the first one.
HandshakeTimeout 10 s Per-path connection-establishment timeout.
AckInterval 20 ms Receiver ACK send period.
RetxInterval 50 ms RTO retransmit timer tick.
MinReorderBytes 1 MB Lower bound of the adaptive reorder-buffer limit.
MaxReorderBytes 32 MB Upper bound of the adaptive limit (clamped to a 64 MB hard cap).

Not in Config (fixed constants): QUIC keep-alive period 15 s, QUIC max idle timeout 45 s.

Testing

go test -race ./...

Covered by tests: reliability under path death (TestReliableRetransmitOnPathDeath), dynamic paths (TestDynamicAddPathLive / TestDynamicRemovePath / TestDynamicAutoCleanup), scheduler, reassembly, estimator, TLS utilities, and deadlock checks.

Status / disclaimer

Experimental project. This is not draft-ietf-quic-multipath — it is bonding over N independent QUIC connections, with Brutal as a per-path fixed-rate CC. Flow control between the application and paths is still coarse: under an extreme RTT spread the reorder buffer can grow up to the hard cap. Intended for authorized use / research on links you own.


Русская версия

🇷🇺 Русский | 🇬🇧 English

MPQ-Brutal — это скрещивание двух идей:

  • multipath-бондинг — агрегация N независимых сетевых каналов (интерфейсов, провайдеров, линков) в один логический поток с суммарной пропускной способностью;
  • Brutal — congestion control из Hysteria2: вместо реакции на потери он держит заданную целевую полосу, компенсируя потерянные пакеты форсированной отправкой. На каналах с потерями Brutal не «схлопывается», как CUBIC/BBR.

Итог: несколько каналов складываются в общую скорость, потери невелируются Brutal'ом на каждом канале по отдельности, а система сама решает, сколько тянуть с каждого канала — быстрый путь получает больше данных, медленный меньше, мёртвый выбывает.

Сессия реализует io.ReadWriteCloser — снаружи это обычный надёжный упорядоченный поток байт, внутри — распределённый по нескольким QUIC-путям транспорт.

Ключевые возможности

  • Агрегация N QUIC-путей в одну bonding-сессию (суммарная полоса).
  • Brutal CC на путь — каждый путь это отдельное QUIC-соединение со своим экземпляром Brutal (internal/congestion/brutal) и своим UDP-сокетом, что позволяет привязывать пути к разным локальным адресам / интерфейсам.
  • BBR-подобный estimator на путь (core/estimator.go) — оконные фильтры minRTT / maxBandwidth плюс фазовая машина probe / drain / steady с гистерезисом; сам определяет реальную ёмкость канала и передаёт целевой bps в Brutal, зажимая его в [MinBps, MaxBps].
  • Pull-планировщик (core/scheduler.go) — автобаланс по pull-модели: воркер пути сам «вытягивает» следующий чанк, когда готов отправлять. Быстрый путь освобождается чаще и забирает больше чанков — распределение пропорционально реальной ёмкости без явного расчёта весов.
  • Прикладная надёжность — per-session ACK-слой (cumulative ACK + SACK) поверх бондинга. Неподтверждённые чанки переотправляются по двум триггерам: мгновенно при смерти пути (все его чанки уходят живым путям) и по RTO (max(50 мс, 2×RTT)) иначе. Дубликаты гасит reorder-буфер.
  • Fast-retransmit застрявшей головы — SACK-driven досрочный ретрансмит дырки head-of-line на самый быстрый живой путь, с anti-spam (порог dup-ACK, временной флор по RTT, лимит на один ACK).
  • Адаптивный reorder-буфер (core/reasm.go) — лимит окна приёма пересчитывается в каждом ack-цикле (≈ сумма EstimatedBps живых путей × разброс RTT), зажат в [MinReorderBytes, MaxReorderBytes] с hard cap 64 МБ. Против дедлока: чанк seq == nextSeq принимается всегда, даже сверх лимита; иначе backpressure.
  • Send-side flow control (rwnd) — приёмник объявляет окно приёма в каждом ACK; отправитель держит неподтверждённый хвост в его пределах (стартовое окно 16 чанков до первого ACK).
  • Грациозный half-close — кадр FIN + CloseWrite(): закрытие одного направления не рубит встречное; полное закрытие — только после дренажа обоих.
  • Динамические пути на летуSession.AddPath(ctx, localAddr) / Session.RemovePath(pathID) меняют набор путей живой сессии без прерывания передачи (сервер подхватывает новое соединение с тем же sessionID как дополнительный путь, а не новую сессию). Уважается MaxPaths; последний путь удалить нельзя; сам умерший путь авто-очищается без утечек горутин.
  • TLS с SPKI-пиннингом — SHA-256 от SubjectPublicKeyInfo серта сервера. Клиент доверяет через -pin (пиннинг публичного ключа, constant-time сравнение), -ca (PEM-якорь доверия) или явный -insecure. Без одного из них клиент не стартует.
  • QUIC keep-alive — PING (15 с) при большем idle-таймауте (45 с), чтобы простаивающие пути не умирали по NAT / idle-таймауту QUIC.

Как это работает

                        ┌────────────────────────────────┐
   Write([]byte) ─────► │  Session (io.ReadWriteCloser)   │ ◄──── Read([]byte)
                        └───────────────┬─────────────────┘
                                        │  чанки seq/payload
                 ┌──────────────────────┴──────────────────────┐
                 │            pull-планировщик                  │  автобаланс:
                 │   (быстрый путь вытягивает больше чанков)    │  pull-модель
                 └───┬───────────────┬────────────────────┬────┘
                     │               │                    │
              ┌──────┴──────┐ ┌──────┴──────┐      ┌──────┴──────┐
              │   Path 0    │ │   Path 1    │  ...  │   Path N    │
              │ QUIC-conn   │ │ QUIC-conn   │       │ QUIC-conn   │
              │ + Brutal CC │ │ + Brutal CC │       │ + Brutal CC │
              │ + estimator │ │ + estimator │       │ + estimator │
              └──────┬──────┘ └──────┬──────┘      └──────┬──────┘
                     └───────────────┴────────────────────┘
                                     │  приходящие чанки (seq)
                        ┌────────────┴─────────────┐
                        │   reorder-буфер (по seq)  │ ► упорядоченный Read
                        └───────────────────────────┘
  • Путь (Path) — одно QUIC-соединение со своим bidirectional data-стримом и своим экземпляром Brutal. Отдельный UDP-сокет позволяет биндить пути к разным локальным адресам / интерфейсам.
  • Estimator — ведёт полосу каждого пути отдельно оконными фильтрами minRTT / maxBandwidth (goodput берётся честно — из подтверждённых ACK'ами байт по каждому пути) и фазовой машиной probe/drain/steady; ставит целевой bps Brutal в [MinBps, MaxBps].
  • Pull-планировщик — без явных весов: готовый воркер пути забирает очередной чанк из общей очереди. Brutal на быстром пути отправляет чанк раньше → воркер раньше освобождается → берёт следующий, поэтому быстрый путь естественно несёт пропорционально больше трафика. Невывезенный чанк мёртвого пути подхватывает живой сосед.
  • Надёжность + reorder — чанки приходят по разным путям вперемешку; ACK-слой (cumAck + SACK) ведёт ретрансмит, а reorder-буфер восстанавливает порядок по seq перед отдачей в Read.

Почему bonding поверх N QUIC, а не «настоящий» multipath-QUIC

Живого draft-ietf-quic-multipath на Go нет: исторический mp-quic мёртв, а upstream quic-go multipath не реализовал. Поэтому вместо одного соединения с несколькими путями мы поднимаем N обычных QUIC-соединений и делаем бондинг на уровень выше.

Ключевой момент: настоящему multipath-QUIC нужен coupled congestion control (общий для путей, чтобы честно делить общий bottleneck). А Brutal — это per-path фиксированная целевая полоса, и она идеально ложится на схему «каждый канал сам по себе»: связывать CC путей не нужно, каждый путь держит свою полосу, а агрегацию делает планировщик. Так «недостаток» (нет общего CC) оказывается ровно тем, что нужно.

Wire-формат (кратко)

  • Хендшейк-кадр: magic="MPQB" + version(1) + sessionID(16 байт) + pathID(1) + role(1: client/server). Все пути одной сессии несут общий sessionID — так сервер группирует входящие соединения в одну bonding-сессию.
  • Data-кадр: type=0x01 + seq(uint64) + len(uint32) + payload (seq — сквозной порядковый номер чанка; payload ≤ 8 КиБ).
  • Ack-кадр: type=0x02 + cumAck(uint64) + rwnd(uint32) + count(uint16)
    • SACK seq × count.
  • Fin-кадр: type=0x03 + finalSeq(uint64) (грациозное полузакрытие записи).

Требования

  • Go 1.25+ (см. go.mod).
  • Ядро зависит от форка apernet/quic-go (QUIC-форк Hysteria2). Сами CLI — чистый stdlib.

Сборка

go build ./cmd/...      # соберёт mpqb-server и mpqb-client

Бинари: cmd/mpqb-servermpqb-server, cmd/mpqb-clientmpqb-client.

Запуск

Сервер (mpqb-server)

Флаг Дефолт Назначение
-listen :4242 QUIC-адрес прослушивания host:port.
-target TCP-адрес назначения для проксирования (напр. 127.0.0.1:5201).
-bench false режим бенча: вычитывать сессию в io.Discard, печатать скорость.
-init-bps 50mbps стартовая целевая полоса Brutal на путь.
-min-bps 1mbps нижняя граница авто-оценки полосы пути.
-max-bps 1gbps верхняя граница авто-оценки полосы пути.
-cert PEM-серт сервера (только вместе с -key); пусто = самоподписанный на лету.
-key PEM-ключ сервера (только вместе с -cert).
-cert-out сохранить сгенерированный самоподписанный серт в PEM (для -ca на клиенте).

Нужно указать либо -target (прокси), либо -bench. При старте сервер печатает SPKI-пин (sha256/<hex> от SubjectPublicKeyInfo leaf-серта) — передайте его клиенту через -pin.

# рядом с iperf3-сервером (iperf3 -s на :5201)
mpqb-server -listen :4242 -target 127.0.0.1:5201 -init-bps 200mbps -max-bps 1gbps
# → печатает: SPKI-пин сервера: sha256/<HEX>  (передайте клиенту через -pin <HEX>)

Клиент (mpqb-client)

Флаг Дефолт Назначение
-server 127.0.0.1:4242 QUIC-адрес сервера host:port.
-locals CSV локальных адресов путей (напр. 127.0.0.1:0,127.0.0.1:0); каждый = отдельный путь; пусто = один путь.
-listen локальный TCP-адрес для туннелирования; каждое TCP-подключение → сессия.
-bench false режим бенча: лить данные в сессию и печатать скорость.
-duration 10s длительность бенча.
-init-bps 50mbps стартовая целевая полоса Brutal на путь.
-min-bps 1mbps нижняя граница авто-оценки полосы пути.
-max-bps 1gbps верхняя граница авто-оценки полосы пути.
-pin ожидаемый SPKI-пин сервера (SHA-256 hex, печатается сервером) → включает пиннинг.
-ca PEM CA/серт сервера для доверия (альтернатива -pin).
-servername имя для SNI/верификации (по умолчанию — хост из -server).
-insecure false ЯВНО отключить проверку TLS (уязвимо к MITM); только для отладки.

Нужно указать либо -listen (туннель), либо -bench. TLS обязателен: клиент требует ровно один из -pin, -ca, -insecure и без него не стартует (молча небезопасного режима нет).

# туннель: локальный TCP :5201 → QUIC-сервер, два пути, пиннинг серта
mpqb-client -server SERVER_IP:4242 -listen 127.0.0.1:5201 \
    -locals 127.0.0.1:0,127.0.0.1:0 \
    -pin <HEX> -init-bps 200mbps -max-bps 1gbps

# гоним трафик через туннель
iperf3 -c 127.0.0.1 -p 5201

Значения полосы (-init-bps / -min-bps / -max-bps) человеко-читаемые: 500kbps, 50mbps, 1gbps. Суффикс задаёт биты в секунду (без суффикса — тоже биты/с); внутри всё переводится в байты/с.

Режимы TLS:

  • -pin <hex> — пиннинг публичного ключа сервера (SPKI SHA-256, constant-time сравнение). Лучший дефолт: не нужен общий CA, защита от MITM.
  • -ca <file.pem> — доверие CA/серту из PEM (в паре с -cert-out на сервере).
  • -insecure — без проверки, с громким предупреждением. Только отладка.

Бенчмарк

Встроенный режим -bench меряет goodput (полезные байты приложения, принятые через Write, без ретрансмитов — честно, в отличие от байт провода, которые под потерями завышены переотправками).

# терминал 1 — сервер в режиме бенча
mpqb-server -listen 127.0.0.1:4242 -bench -init-bps 200mbps -max-bps 1gbps

# терминал 2 — клиент льёт через 2 пути 10 секунд (на loopback можно -insecure)
mpqb-client -server 127.0.0.1:4242 -bench -duration 10s \
    -locals 127.0.0.1:0,127.0.0.1:0 -insecure -init-bps 200mbps -max-bps 1gbps

Или всё сразу:

./scripts/bench-loopback.sh

Пример вывода клиента (2 пути, loopback) — оба пути реально несут трафик, сумма складывается:

бенч: сессия из 2 путь(ей) на 10s
итог: goodput 143.48 MiB за 10.00 c → 0.398 Гбит/с суммарно (полезные байты приложения, без ретрансмитов)
  путь 0: sent(провод)=61.95 MiB rtt=40µs est=0.165 Гбит/с alive=true
  путь 1: sent(провод)=81.54 MiB rtt=34µs est=0.255 Гбит/с alive=true

Проверка на дефектном канале

Brutal показывает себя именно на каналах с потерями. Эмулируем потери и задержку на loopback через tc netem (нужен sudo):

# включить 5% потерь + 20ms задержки на lo
sudo tc qdisc add dev lo root netem loss 5% delay 20ms

# прогнать бенч (см. выше) — Brutal держит целевую полосу несмотря на потери,
# тогда как loss-реактивный CC на 5% потерь просел бы в разы
./scripts/bench-loopback.sh

# откат
sudo tc qdisc del dev lo root

Удобная обёртка:

sudo ./scripts/netem-loss.sh on 5% 20ms   # включить
sudo ./scripts/netem-loss.sh off          # откатить

CUBIC/BBR трактуют потерю как сигнал перегрузки и режут окно — на «шумном» канале скорость обваливается. Brutal держит заданную полосу и дожимает потерянное форсированной отправкой — на дефектном канале это даёт кратно бо́льшую фактическую пропускную способность.

Конфигурация

Публичный core.Config (см. core/mpq.go); нулевое значение = «дефолт»:

Поле Дефолт Смысл
RemoteAddr Адрес сервера host:port (клиент / Dial).
LocalAddrs Локальные bind-адреса путей; пусто = один путь по умолчанию.
ListenAddr Адрес прослушивания host:port (сервер / Listen).
TLSConfig TLS-настройки; сервер обязан задать серты. Пустой ALPN → mpq-brutal.
InitialBps 50 Мбит/с Стартовая целевая полоса Brutal на путь (байт/с).
MinBps 1 Мбит/с Нижняя граница авто-оценки полосы пути.
MaxBps 1 Гбит/с Верхняя граница авто-оценки полосы пути.
ProbeInterval 200 мс Период тика оценщика.
MaxPaths 8 Максимум путей в сессии (hard cap 256, т.к. pathID — байт).
ExpectedPaths 0 Сервер: сколько путей ждать до формирования сессии (0 = по таймауту).
PathGatherTimeout 300 мс Сервер: таймаут сбора путей после первого.
HandshakeTimeout 10 с Таймаут установления соединения на путь.
AckInterval 20 мс Период отправки прикладных ACK приёмником.
RetxInterval 50 мс Период тика RTO-таймера ретрансмита.
MinReorderBytes 1 МБ Нижняя граница адаптивного лимита reorder-буфера.
MaxReorderBytes 32 МБ Верхняя граница адаптивного лимита (зажата hard cap 64 МБ).

Не в Config (фиксированные константы): период QUIC keep-alive 15 с, QUIC idle-таймаут 45 с.

Тестирование

go test -race ./...

Покрыто тестами: надёжность при смерти пути (TestReliableRetransmitOnPathDeath), динамические пути (TestDynamicAddPathLive / TestDynamicRemovePath / TestDynamicAutoCleanup), планировщик, reassembly, estimator, TLS-утилиты и проверки на дедлок.

Статус / дисклеймер

Экспериментальный проект. Это не draft-ietf-quic-multipath, а бондинг поверх N независимых QUIC-соединений с Brutal как per-path CC с фиксированной полосой. Flow-control между приложением и путями всё ещё грубоват: при экстремальном разбросе RTT reorder-буфер может вырасти до hard cap. Для авторизованного использования / исследований на своих каналах.

About

MP-QUIC × Brutal: multipath QUIC bonding with Brutal (Hysteria2) congestion control — sum multiple links, punch through packet loss

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages