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 | 🇷🇺 Русский
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.
- 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) — windowedminRTT/maxBandwidthfilters 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-pathEstimatedBps× RTT spread), clamped to[MinReorderBytes, MaxReorderBytes]with a hard cap of 64 MB. Deadlock-safe: theseq == nextSeqchunk 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
FINframe +CloseWrite(): closing one direction does not tear down the reverse one; full close only after both directions drain. - Dynamic paths on the fly —
Session.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 samesessionIDas an extra path, not a new session). RespectsMaxPaths; 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.
┌────────────────────────────────┐
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/maxBandwidthfilters (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
seqbefore handing bytes toRead.
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.
- Handshake frame:
magic="MPQB"+version(1)+sessionID(16 B)+pathID(1)+role(1: client/server). All paths of a session carry the samesessionID, which is how the server groups incoming connections into one bonding session. - Data frame:
type=0x01+seq(uint64)+len(uint32)+payload(seqis the session-wide chunk order number; payload ≤ 8 KiB). - Ack frame:
type=0x02+cumAck(uint64)+rwnd(uint32)+count(uint16)+ SACKseq × count. - Fin frame:
type=0x03+finalSeq(uint64)(graceful write half-close).
- Go 1.25+ (see
go.mod). - Core depends on the
apernet/quic-gofork (the Hysteria2 QUIC fork). The CLIs themselves are pure stdlib.
go build ./cmd/... # builds mpqb-server and mpqb-clientBinaries come from:
cmd/mpqb-server→mpqb-servercmd/mpqb-client→mpqb-client
| 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>)| 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 5201Bandwidth 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.
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 1gbpsOr all at once:
./scripts/bench-loopback.shSample 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
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 rootConvenience wrapper:
sudo ./scripts/netem-loss.sh on 5% 20ms # enable
sudo ./scripts/netem-loss.sh off # revertCUBIC/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.
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.
go test -race ./...Covered by tests: reliability under path death (TestReliableRetransmitOnPathDeath),
dynamic paths (TestDynamicAddPathLive / TestDynamicRemovePath /
TestDynamicAutoCleanup), scheduler, reassembly, estimator, TLS utilities, and
deadlock checks.
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.
Живого draft-ietf-quic-multipath на Go нет: исторический mp-quic мёртв, а
upstream quic-go multipath не реализовал. Поэтому вместо одного соединения с
несколькими путями мы поднимаем N обычных QUIC-соединений и делаем бондинг
на уровень выше.
Ключевой момент: настоящему multipath-QUIC нужен coupled congestion control (общий для путей, чтобы честно делить общий bottleneck). А Brutal — это per-path фиксированная целевая полоса, и она идеально ложится на схему «каждый канал сам по себе»: связывать CC путей не нужно, каждый путь держит свою полосу, а агрегацию делает планировщик. Так «недостаток» (нет общего CC) оказывается ровно тем, что нужно.
- Хендшейк-кадр:
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.
- SACK
- 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-server → mpqb-server, cmd/mpqb-client → mpqb-client.
| Флаг | Дефолт | Назначение |
|---|---|---|
-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>)| Флаг | Дефолт | Назначение |
|---|---|---|
-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. Для авторизованного
использования / исследований на своих каналах.