6 releases
| 0.2.2 | Jun 5, 2026 |
|---|---|
| 0.2.0 | Jun 4, 2026 |
| 0.1.130 | Jun 2, 2026 |
| 0.1.102 | Apr 17, 2026 |
#35 in #file-transfer
480KB
8K
SLoC
mftp
High-throughput file transfer over high-latency links.
mftp is built for the scenarios where scp crawls: satellite uplinks, intercontinental hops, anything where bandwidth × delay product is large. It multiplexes a single file across parallel QUIC streams, adapts chunk size and stream count to measured RTT, and compresses on the fly — while keeping the UX as simple as scp.
mftp send dataset.tar.gz user@remote-host:/data/
In SSH mode, mftp launches the receiver automatically over your existing SSH session. The primary transfer uses QUIC or TCP+TLS (both require an open port on the receiver); if those ports are blocked, mftp falls back to parallel SFTP through port 22 with no extra configuration.
Features
| QUIC transport | Parallel streams over a single connection; no TCP head-of-line blocking |
| BBR congestion control | Measures bandwidth and RTT directly; avoids CUBIC's sawtooth pattern on lossy/high-latency links |
| Auto TCP+TLS fallback | If UDP is blocked, retries transparently over TCP+TLS; also auto-switches on LAN/datacenter links (RTT ≤ 15 ms) where kernel TCP beats QUIC |
| SSH-assisted launch | Spawns the receiver on the remote via SSH — no manual setup |
| SFTP fallback | If both QUIC and TCP+TLS are blocked, falls back to N parallel SFTP connections through port 22 — only this path requires no open port beyond SSH. Significantly slower (~22–32 MiB/s cap) due to SSH SFTP protocol overhead |
| Adaptive compression | Per-chunk zstd; skips chunks that don't compress (already-compressed formats auto-detected) |
| Reed-Solomon FEC | Optional parity shards (--fec DATA:PARITY). Note: does not improve throughput over QUIC (which already recovers loss) — see Reed-Solomon FEC |
| End-to-end integrity | BLAKE3 per chunk (raw bytes) + full-file BLAKE3 verified on arrival |
| RTT-aware negotiation | Stream count and chunk size auto-tuned from measured round-trip time |
| Resumable transfers | Crash-safe bit-vector tracks received chunks; transfers continue where they left off |
| TOFU authentication | Self-signed certs; fingerprint confirmed once per session; --trust pins it for scripted use |
Quick start
Install
Download a prebuilt binary — latest release: v0.1.130 (Linux x86_64, macOS Intel + Apple Silicon, Windows x86_64; each with a .sha256):
# Linux x86_64 — verify the checksum, then install
curl -LO https://github.com/OmarHermannsson/mftp/releases/latest/download/mftp-linux-x86_64
curl -LO https://github.com/OmarHermannsson/mftp/releases/latest/download/mftp-linux-x86_64.sha256
sha256sum -c mftp-linux-x86_64.sha256
chmod +x mftp-linux-x86_64 && sudo mv mftp-linux-x86_64 /usr/local/bin/mftp
For macOS/Windows, grab the matching asset (mftp-macos-aarch64, mftp-macos-x86_64, mftp-windows-x86_64.exe) from the releases page.
Or with Cargo:
cargo install mftp
Or from git:
cargo install --git https://github.com/OmarHermannsson/mftp
Or build from source:
git clone https://github.com/OmarHermannsson/mftp
cd mftp
cargo build --release
# binary is at target/release/mftp
Send a file (SSH mode)
# mftp SSHes to the remote, starts the receiver, transfers, and cleans up
mftp send bigfile.tar.gz user@remote-host:/data/landing/
mftp connects via your existing SSH credentials. The receiver is started automatically and exits when the transfer completes. It tries QUIC first, then TCP+TLS, then SFTP — falling back automatically if the direct transfer ports are blocked.
Send a file (manual receiver)
On the receiver:
mftp receive --output-dir /data/landing
# Prints: Listening on 0.0.0.0:7777 (QUIC + TCP+TLS, auto-fallback)
# Prints: Certificate fingerprint: a3f9...
On the sender:
mftp send bigfile.tar.gz remote-host:7777 --trust a3f9...
The receiver port (7777 by default) must be reachable: UDP for QUIC, TCP for TCP+TLS, or both. There is no SFTP fallback in direct host:port mode.
Examples by link type
mftp auto-tunes for the measured RTT, so the defaults are usually right. These show the flags worth setting when you know the link in advance.
Satellite / very high latency (RTT ≳ 300 ms, some loss). The defaults are right: QUIC shines on long fat pipes and recovers loss via its own retransmission. Just let it run:
mftp send dataset.tar user@ground-station:/data/
Don't reach for
--fechere. Benchmarking up to 30% loss shows FEC does not improve throughput over QUIC — QUIC already retransmits lost packets reliably, so the parity shards are pure overhead competing for the loss-limited link. See Reed-Solomon FEC.
Intercontinental fibre (RTT ~150 ms, low loss). The defaults are tuned for exactly this. Pin a higher stream count only if you have spare CPU and the single-flow rate is capped by per-stream flow control:
mftp send dataset.tar user@remote:/data/ -n 16
LAN / datacenter (RTT ≤ 15 ms).
mftp automatically prefers TCP+TLS below --tcp-below-rtt (15 ms) since QUIC's ramp loses to TCP on fast links. Compression usually just burns CPU on a fast LAN, so disable it for already-incompressible data:
mftp send disk.img user@nas:/pool/ --no-compress
# force the transport explicitly if RTT estimation is unreliable:
mftp send disk.img user@nas:/pool/ --transport tcp --no-compress
Sending a whole directory (any link): add -r, and --preserve to keep mtimes and permissions:
mftp send ./project user@remote:/backups/ -r --preserve
Usage
mftp send
mftp send [OPTIONS] <FILE> <DESTINATION>
DESTINATION is either:
host:port— connect to an already-runningmftp receive. The port must be reachable (UDP for QUIC, TCP for TCP+TLS).[user@]host:/remote/path— launch the receiver via SSH (recommended). Falls back through QUIC → TCP+TLS → SFTP automatically.
Options:
--trust <FINGERPRINT> Pin the receiver's SHA-256 certificate fingerprint.
Omit to use TOFU (fingerprint is printed and you are
prompted to accept it once per session; it is not
automatically stored between sessions).
Ignored in SSH mode — fingerprint is read from the server.
--remote-mftp <PATH> Path to a pre-installed mftp on the remote host.
By default mftp pipes itself over SSH stdin on first use
and caches it at ~/.cache/mftp-<hash> on the remote.
--port <PORT> Port the remote mftp server should bind on (SSH mode only).
Defaults to a randomly assigned port. Use this when the
transfer port must be in a firewall allow-list.
-n, --streams <N> Parallel streams.
Direct mode: default auto-negotiated from RTT + CPU cores.
By default the sender adapts stream count mid-transfer
based on measured throughput and receiver congestion —
passing -n N pins the count and disables scaling.
SFTP: default 8 (each stream = one SSH connection;
raise to 12 if the remote sshd allows it).
--chunk-size <BYTES> Chunk size in bytes (default: auto from RTT).
--no-compress Disable adaptive zstd compression.
-r, --recursive Transfer directories recursively. Required when the
source is a directory; silently accepted (no-op) when
the source is a regular file.
--preserve Preserve source file permissions and modification time
on the receiver. No effect on Windows receivers.
--fec <DATA:PARITY> Enable Reed-Solomon forward error correction.
e.g. --fec 8:2 adds 25% bandwidth overhead.
NOTE: this does NOT improve throughput — QUIC already
retransmits lost packets reliably, so the parity is
pure overhead (benchmarked a wash-to-worse up to 30%
loss). Leave it off unless you specifically want a
receiver to reconstruct an occasional corrupt chunk
without an in-band repair round. Auto-disabled on the
TCP fallback (reliable delivery).
--transport <TRANSPORT>
Force a specific transport path:
quic — QUIC only; fails immediately if UDP is blocked
(no TCP+TLS or SFTP fallback).
tcp — TCP+TLS only; skip the QUIC probe (no SFTP fallback).
sftp — parallel SFTP through port 22 (SSH mode only;
significantly slower, ~22 MiB/s cap; skips remote
server launch).
Omit for auto: QUIC → TCP+TLS → SFTP (SSH mode only).
--tcp-below-rtt <MS> In auto mode, switch to TCP+TLS when measured RTT ≤ this
value. Ignored when --transport is set [default: 15].
--download When the remote platform differs from local, automatically
download the correct mftp binary from GitHub releases
without prompting. Mutually exclusive with --no-download.
--no-download When the remote platform differs from local, skip the
download attempt and fall back to SFTP immediately.
Mutually exclusive with --download.
--remote-binary-sha256 <HEX>
Expected SHA-256 of the cross-platform binary fetched
from GitHub releases. When set, the download must match
exactly or the transfer aborts before anything runs on
the remote. Without it, the download is still checked
against the release's published <asset>.sha256 and the
computed hash is printed for manual comparison.
-v, --verbose Increase log verbosity (-v / -vv / -vvv).
mftp receive
mftp receive [OPTIONS] [BIND]
BIND defaults to 0.0.0.0:7777. Both QUIC (UDP) and TCP+TLS listen on the same port, so the sender's auto-fallback works with no extra configuration on the receiver side. The bind port must be reachable from the sender.
Options:
-o, --output-dir <DIR> Directory to write received files into (default: .).
FEC parameters are negotiated automatically via the TransferManifest — no --fec flag needed on the receiver.
mftp --version
mftp 0.1.119
How it works
Transport
mftp defaults to QUIC (via quinn) with BBR congestion control. In SSH mode it tries three transports in order, falling back automatically:
Sender Receiver
│ │
├─── QUIC connect (5 s timeout) ──────────►│ mftp receiver (launched via SSH)
│ (if UDP blocked or times out) │ requires: data port open (UDP)
├─── TCP+TLS connect ──────────────────────►│ mftp receiver (same process)
│ (if TCP port also unreachable) │ requires: data port open (TCP)
└─── SFTP (N parallel SSH connections) ───►│ sshd sftp-server (port 22 only)
│ requires: SSH port 22 only
The QUIC and TCP+TLS paths use TLS 1.3 with a freshly generated self-signed certificate shared between both transports (same fingerprint). The SFTP path bypasses the mftp receiver entirely and writes directly to the remote filesystem via the sshd built-in sftp-server subsystem.
SSH-assisted launch
When you write mftp send file.bin user@host:/path, the sender:
- SSHes to
user@hostand runsmftp server --output-dir /path- If mftp is not installed on the remote, the local binary is piped over SSH stdin and cached at
~/.cache/mftp-<hash>— subsequent transfers with the same binary version skip the copy. - The remote OS and architecture are probed first so the correct binary is delivered (Linux/macOS/Windows, x86_64/arm64).
- Use
--port <N>to have the remote server bind on a specific port instead of a random one (required when the transfer port must be in a firewall allow-list; without--port, the random port will almost certainly be blocked).
- If mftp is not installed on the remote, the local binary is piped over SSH stdin and cached at
- The remote server binds on the chosen (or random) port and prints one JSON line to stdout:
{"port":54321,"fingerprint":"a3f9..."} - The sender reads the handshake, then attempts a direct connection to
host:54321 - If the direct connection fails (firewall blocks the port), the sender falls back to parallel SFTP — N independent SSH/SFTP connections each writing a non-overlapping segment of the file directly to the remote. No mftp process is needed on the remote for this leg; it talks to the sshd sftp-server subsystem.
The remote mftp server exits as soon as the transfer completes (or is killed when SFTP takes over).
Firewall configuration
The QUIC and TCP+TLS paths both require a port to be open on the receiver — UDP for QUIC, TCP for TCP+TLS (or both, since the auto-fallback tries both). In SSH mode a random port is picked by default, which will almost certainly be blocked by a firewall; use --port <N> to pick a specific port you control.
Opening a port temporarily (Linux)
# iptables (most distributions)
sudo iptables -A INPUT -p udp --dport 7777 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 7777 -j ACCEPT
# firewalld (RHEL/Fedora/Rocky/AlmaLinux)
sudo firewall-cmd --add-port=7777/udp --add-port=7777/tcp
# ufw (Ubuntu/Debian)
sudo ufw allow 7777/udp
sudo ufw allow 7777/tcp
These rules are not persistent across reboots. To persist them, add --permanent to firewall-cmd (then sudo firewall-cmd --reload), use iptables-save, or add a ufw allow rule before enabling ufw persistence.
Cloud providers: security group or VPC firewall rules (AWS, GCP, Azure, etc.) must also permit the port — kernel-level rules alone are not enough on cloud instances. The rule must allow inbound traffic from the sender's public IP on the chosen port (UDP and TCP).
If you cannot open a port: the automatic SFTP fallback (port 22 only) requires no additional firewall changes. It is slower — typically 22–32 MiB/s versus the full QUIC/TCP throughput — but works through any firewall that allows SSH.
SFTP fallback
The SFTP fallback uses libssh2 to open N independent SSH connections to port 22, each with its own SFTP channel. The file is divided into N equal segments; each connection writes its segment with positional I/O (seek + write) in parallel.
Throughput scales linearly with stream count because each connection is fully independent (separate congestion window, separate SSH channel):
| Streams | Throughput (LAN, 500 MiB) |
|---|---|
| 4 | ~12 MiB/s |
| 8 | ~22 MiB/s (default) |
| 12 | ~32 MiB/s |
The ceiling per stream (~3 MiB/s) comes from libssh2's synchronous SFTP write acknowledgment — it is a fundamental limitation of the SSH SFTP protocol. Raising --streams to 12 is safe on most servers; beyond that, OpenSSH's MaxStartups setting (default 10:30:100) may start rate-limiting concurrent auth attempts.
The SFTP path uses a single encryption layer (SSH), whereas the tunnel approach used SSH + TLS. Authentication uses the SSH agent if running, otherwise the default key files (~/.ssh/id_ed25519, id_rsa, id_ecdsa). The remote host key is verified against ~/.ssh/known_hosts.
Parallel streams and RTT negotiation
After the QUIC handshake, sender and receiver exchange CPU core counts. The sender then reads the measured RTT from the QUIC connection stats and computes:
| RTT | Default chunk size |
|---|---|
| < 10 ms (LAN) | 8 MiB |
| 10 – 200 ms (regional/intercontinental) | 4 MiB |
| ≥ 200 ms (satellite) | 2 MiB |
Stream count is max(⌈RTT_ms / 5⌉, min_cores), capped at 2 × min(sender_cores, receiver_cores). On a satellite link with 600 ms RTT and an 8-core machine on each end, mftp opens 16 streams of 2 MiB chunks — keeping the pipe full while staying within CPU budget.
Both values can be overridden with --streams and --chunk-size.
Data flow
File on disk
└─► ChunkQueue (atomic work-stealing index)
└─► N parallel tasks, one per QUIC/TCP stream:
├─ read chunk from file (pread)
├─ detect already-compressed format (magic bytes)
├─ BLAKE3 hash of raw chunk bytes
├─ zstd compress full chunk; discard if < 5% gain
├─ [FEC] accumulate DATA shards into stripe; RS-encode PARITY shards
└─ send ChunkData / FecChunkData frame (hash + payload)
Control stream (after all data streams finish):
Sender ──► SenderMessage::Complete { file_hash }
Receiver ──► ReceiverMessage::Retransmit { chunks } (v5, if any chunk missing)
└─ Sender re-sends those chunks as ChunkData here, then Complete again
Receiver ──► ReceiverMessage::Complete { file_hash } (after full-file verify)
The receiver writes each chunk directly to its final offset with pwrite, so no reassembly pass is needed after the transfer.
Wire protocol
All messages are length-prefixed bincode frames ([u32 LE length][payload]):
Control stream (1 per connection):
Sender → NegotiateRequest { cpu_cores }
Receiver → NegotiateResponse { cpu_cores }
Sender → TransferManifest { transfer_id, file_name, file_size, chunk_size,
total_chunks, num_streams, compression, fec }
Receiver → ReceiverMessage::Ready { received_bits, total_chunks } ← resume bitvector
...data streams transfer...
Sender → SenderMessage::Complete { file_hash }
Receiver → ReceiverMessage::Retransmit { chunks } (v5; repair over this stream)
Receiver → ReceiverMessage::Complete { file_hash }
Data streams (N per connection):
Without FEC — one ChunkData per chunk:
Sender → ChunkData { transfer_id, chunk_index, chunk_hash, compressed, payload }
With FEC — one FecChunkData per shard (data + parity):
Sender → FecChunkData { transfer_id, chunk_index, chunk_hash, compressed,
stripe_index, shard_index_in_stripe, is_parity,
shard_lengths, payload }
Reed-Solomon FEC
Enable with --fec DATA:PARITY on the sender (e.g. --fec 8:2). The receiver accepts both FEC and non-FEC transfers on the same port — the TransferManifest advertises whether FEC is active.
Stripe layout: chunks are grouped into stripes of DATA shards. Each stripe is RS-encoded to produce PARITY additional shards. All DATA + PARITY shards are sent independently across the parallel streams. The receiver can reconstruct any stripe as long as it receives at least DATA out of the DATA + PARITY shards — tolerating up to PARITY lost shards per stripe without retransmission. A stripe that loses more than PARITY shards is recovered by in-band incremental repair (see below) rather than aborting the transfer.
Ordering: compression happens before FEC encoding, so parity shards are computed over (and are the same size as) compressed data shards. FEC is automatically disabled when the transport falls back to TCP, since TCP guarantees delivery.
Overhead: --fec 8:2 adds 25% to the wire size (2/8).
FEC does not improve throughput over QUIC — leave it off. Because mftp's chunks ride reliable QUIC streams, QUIC already retransmits every lost packet, so chunks always arrive intact and there is no permanent gap for parity to fill. The parity shards are simply extra bytes competing for the (loss-limited) link, and they are subject to the same loss + retransmission. Benchmarks bear this out: across 1%, 5%, 10%, 20% and 30% loss (and 300 ms RTT),
--fecis a wash-to-worse versus plain QUIC, and heavy parity (4:4) is reliably slower. FEC's only real value is letting the receiver reconstruct the occasional chunk that fails its BLAKE3 check (or rode a reset stream) without a repair round-trip — a niche reliability edge, not a throughput lever. It would pay off over an unreliable/datagram transport, which mftp does not use.
Compression
mftp compresses each chunk independently with zstd:
- Magic-byte check — if the first 4 bytes match a known compressed format (gzip, zstd, bzip2, zip, 7-zip, xz, jpeg, png, mp4, mkv/webm…), compression is skipped entirely.
- Sample probe — a 64 KiB leading sample is compressed first; if it doesn't shrink by ≥ 5%, the chunk is sent raw without compressing the rest, so incompressible data not caught by the magic-byte table costs only the sample, not the full chunk.
- Full-chunk compression — if the sample compresses well, the whole chunk is compressed; a final 5% threshold still applies (only the leading portion may have been compressible), and the compressed bytes are discarded if it isn't met.
- Adaptive level — a per-worker EMA of the achieved ratio picks the zstd level: 1 for near-incompressible data, 3 otherwise. It deliberately does not escalate to level 6 — measured, that costs ~2× the CPU for only ~7% better ratio, which reduces throughput whenever compression (not the network) is the bottleneck.
- Per-chunk flag —
ChunkData.compressedtells the receiver whether to decompress.
--no-compress skips the whole path. On a fast link (LAN / fast datacenter) compression can be the bottleneck — its CPU cost outweighs the bandwidth saved — so prefer --no-compress there; mftp's auto TCP switch below --tcp-below-rtt doesn't disable compression for you.
Integrity
- Per-chunk: BLAKE3 of the raw (pre-compression) chunk bytes. The sender computes the hash before compressing, embeds it in the frame, and the receiver decompresses then re-computes and compares before writing to disk. A chunk that fails this check is dropped (left unmarked) rather than aborting — see Incremental repair below.
- Full-file: BLAKE3 of the concatenated per-chunk hashes —
blake3(hash[0] || hash[1] || … || hash[N-1]). The sender sends this inSenderMessage::Complete; the receiver verifies it after all chunks land. A mismatch fails the transfer (and deletes the resume file so the next run starts clean). - SFTP fallback: integrity is provided by SSH's channel MAC (HMAC-SHA2-256). Per-chunk and full-file hashing are not available on this path since there is no mftp receiver process.
Incremental repair
When chunks are missing at the completion checkpoint — a dropped corrupt chunk, or an FEC stripe that received too few shards to reconstruct — a protocol-v5 receiver requests exactly those chunks back via ReceiverMessage::Retransmit. The sender re-reads them and re-sends them as plain ChunkData on the existing control stream, so the connection (and its warmed congestion window) stays alive instead of being torn down. This is single-file only and bounded (MAX_REPAIR_ROUNDS); if repair is unavailable or exhausted the transfer fails and a resumed rerun re-fetches only the missing chunks.
Resume
Each transfer has a deterministic 16-byte ID derived from the file name, file size, and negotiated chunk size (BLAKE3(name || size || chunk_size)[..16]). This means re-sending the same file automatically resumes an interrupted transfer — no flags needed. If the negotiation produces different parameters (e.g. different RTT), the ID changes and a fresh transfer starts. The ID is embedded in every ChunkData frame.
On the receiver side, a bit-vector tracking which chunks have been received is flushed to <output_dir>/<transfer_id_hex>.mftp-resume in batches (every 64 chunks) to limit fsync overhead. If the transfer is interrupted, at most 64 chunks may need re-downloading on resume.
If the transfer is interrupted:
- Restart
mftp receive(or re-run the samemftp sendcommand in SSH mode) - The receiver finds the resume file, reads which chunks it already has
- In the
ReceiverMessage::Readyresponse it sends the received-chunk bitvector - The sender skips already-received chunks and only retransmits what's missing
The resume file is deleted on successful completion. Resume is not available on the SFTP fallback path.
Security
mftp uses self-signed TLS certificates with a TOFU (Trust On First Use) model, similar to SSH:
- The receiver generates a fresh key pair on every start
- It prints the SHA-256 fingerprint of its certificate
- On first connect to a new server the sender prompts for confirmation (requires a TTY; non-interactive invocations without
--trustare rejected) - Pass
--trust <fingerprint>to pin a fingerprint for scripted or non-interactive use; the fingerprint is not stored between sessions automatically
For SSH-assisted transfers the fingerprint is obtained automatically over the existing SSH channel — no manual verification step required.
The SFTP fallback path relies on SSH host key verification against ~/.ssh/known_hosts (the same file used by the ssh command). Run ssh <host> once if the host is not yet in your known_hosts.
Socket buffers are set to 32 MiB (SO_SNDBUF / SO_RCVBUF) on both ends. QUIC flow control windows start at 64 MiB per stream and 512 MiB at the connection level; the connection-level window is scaled up dynamically after RTT is measured to match the bandwidth-delay product on high-latency links (e.g. ~1 GiB at 600 ms RTT on a 10 Gbps link).
Performance
mftp vs scp vs zap (parallel-SSH copy), 1 GiB incompressible file, netem-shaped
latency/loss on a gigabit-class link. Median of 3 runs; page cache dropped on both
ends and the received file fsync'd into the timing ("durability-fair", so no tool
is flattered by the receiver's write cache — see tests/compare.sh).
MiB/s of the original file size:
| Link | scp | zap | mftp (auto) |
|---|---|---|---|
| 50 ms RTT | 34 | 62 | 111 |
| 150 ms RTT | 12 | 40 | 106 |
| 150 ms + 1% loss | 9 | 20 | 39 |
scp's single TCP stream collapses as latency rises; zap's parallel SSH streams hold a middle band; mftp's QUIC + adaptive streams stays near-flat (~110 MiB/s from 50→150 ms) — ~2.6× zap and ~9× scp at 150 ms, and the gap widens with RTT.
On a sub-15 ms LAN mftp uses the auto TCP+TLS path, and the receiver's disk write
becomes the bottleneck, not the protocol — durability-fair, mftp sustains ~150+ MiB/s
because it overlaps writeback with the transfer, while a cache-warm measurement can make
scp look momentarily faster simply by buffering into RAM (it then pays a long fsync). For
compressible data mftp pulls further ahead (adaptive zstd ships fewer bytes); --fec does
not help at any loss rate — see Reed-Solomon FEC.
Absolute numbers are testbed- and disk-dependent; the ratios and the RTT-flatness are the point. Reproduce with
tests/compare.sh(setREMOTE_USER/REMOTE_HOST).
Known limitations
- Firewall: the QUIC and TCP+TLS paths both require an open port on the receiver. In SSH mode a random port is used by default — likely to be blocked. Use
--port <N>with a known-open port, or rely on the automatic SFTP fallback (port 22 only, but capped at ~22–32 MiB/s). See Firewall configuration above. - SFTP fallback is significantly slower: the SFTP path is capped at ~3 MiB/s per stream (a fundamental SSH SFTP protocol limitation — synchronous write acknowledgments). At the default of 8 streams that is ~22 MiB/s; 12 streams gives ~32 MiB/s. This is several times slower than the QUIC or TCP+TLS paths, which saturate the link. If transfers are consistently falling back to SFTP, open a port and use
--port <N>. - TOFU fingerprint persistence:
--trustfingerprints are not stored between sessions. You must pass--truston every non-interactive invocation, or accept the prompt each time. - Directory transfer:
-rtransfers the directory tree recursively.--preservecopies mode bits and mtime; without it, files land with default umask permissions and current mtime.
Performance tips
-
Satellite / high-latency links: mftp is designed for these. Let RTT negotiation pick the parameters; don't override unless you have a reason.
-
Lossy links: no action needed — QUIC's own retransmission handles packet loss. Do not add
--fec: benchmarking up to 30% loss shows it doesn't improve throughput (QUIC already recovers loss, so parity is pure overhead) and heavy parity is slower. See Reed-Solomon FEC. -
LAN / datacenter transfers: mftp auto-switches to TCP+TLS when it measures RTT ≤ 15 ms. No flags needed — just run the same command.
-
Pre-compressed data (videos, archives, already-zstd files): mftp auto-detects these and skips compression. No
--no-compressneeded. -
Open port required: in SSH mode, use
--port <N>with a firewall-allowed port to avoid the automatic fallback to SFTP. The SFTP path is reliable but slower. -
SFTP fallback throughput: if the direct transfer ports are always blocked, raise
--streamsfrom the default of 8 to 12 for ~32 MiB/s. Check that the remote sshd'sMaxStartupsis set to at least12:30:100. -
OS socket buffer limit: mftp requests 64 MiB socket buffers, but the Linux kernel clamps to
net.core.rmem_max/wmem_max(it logs a warning if it can't grant the full amount). Raise both on each host for maximum throughput on high-BDP links:sudo sysctl -w net.core.rmem_max=67108864 net.core.wmem_max=67108864On very-high-BDP paths (10 Gbps × high RTT) raise the sysctls further and set
MFTP_SOCKET_BUFFER=<bytes>to request a larger buffer. -
Faster congestion ramp: QUIC starts at a 1 MiB BBR congestion window (raised from quinn's ~234 KiB default) so high-RTT transfers reach line rate a few RTTs sooner. Tune with
MFTP_INITIAL_CWND=<bytes>— lower it on thin/constrained links where a large first-flight burst would cause startup loss. -
TCP BBR on the TCP+TLS path: mftp requests BBR congestion control for TCP sockets on Linux (mirroring the QUIC path). If you see a warning about
TCP_CONGESTION=bbr, thetcp_bbrkernel module is not loaded on that host. Load it manually or persist it across reboots:sudo modprobe tcp_bbr # persist across reboots: echo tcp_bbr | sudo tee /etc/modules-load.d/tcp_bbr.confFailure to load the module is non-fatal — the transfer continues with the kernel default (usually CUBIC). BBR matters most on the TCP+TLS path (auto-switched on LAN, or forced with
--transport tcp). -
Diagnosing slow transfers: in a wide terminal (≥ 140 columns) the progress bar shows a live diagnostic:
streams=N rtt=Xms loss=N stall=Nms. Fields only appear when non-zero. A persistent highstall=value means the receiver disk is the bottleneck; a non-zeroloss=points to network packet loss.
Building
cargo build --release
Requires Rust 1.75+ (for div_ceil stabilization) and libssh2 (for the SFTP fallback). On most Linux distributions libssh2 is already installed; on others install libssh2-devel (RPM) or libssh2-dev (Debian/Ubuntu). On Windows, OpenSSL is vendored automatically — no extra setup needed.
Platform support: Linux (x86_64, arm64), macOS (x86_64, aarch64), and Windows (x86_64) are all supported. CI builds and runs the full test suite on Linux, macOS, and Windows on every change.
# Check only (fast)
cargo check
# Tests
cargo test
# Lint
cargo clippy -- -D warnings
Building on macOS
mftp is fully supported on macOS (x86_64 and aarch64). If you encounter linker errors related to missing zlib symbols (_deflate, _inflate, etc.) during cargo build, this is caused by libz-sys picking up the wrong architecture library from MacPorts or Homebrew. The repository includes a pre-configured .cargo/config.toml that forces static zlib linking and sets the correct deployment target; no additional steps are needed beyond running cargo build --release.
If you need to cross-compile (e.g., building for x86_64 on Apple silicon), the same configuration applies automatically:
# Build for x86_64-apple-darwin (on Apple silicon host)
cargo build --release --target x86_64-apple-darwin
# Output: target/x86_64-apple-darwin/release/mftp
# Build for aarch64-apple-darwin (native on Apple silicon)
cargo build --release --target aarch64-apple-darwin
The .cargo/config.toml settings that enable this:
[env]
LIBZ_SYS_STATIC = "1"
MACOSX_DEPLOYMENT_TARGET = "12.0"
LIBZ_SYS_STATIC=1 forces zlib to be built from source instead of discovering a system library of the wrong architecture, and MACOSX_DEPLOYMENT_TARGET=12.0 ensures C dependencies target macOS Monterey or later for binary compatibility.
Roadmap
- Fingerprint persistence — store
--trustfingerprints across sessions (keyed by host)
For shipped features and per-release history, see CHANGELOG.md and the releases page.
License
MIT
Dependencies
~41–65MB
~1M SLoC