On this page
- Quick capability matrix
- One-command install + OS tuning
- Kernel requirements
- Choosing network.rx_backend
- io_uring (default) — lowest CPU, best for most deployments
- af_packet — lower burst loss, higher CPU
- AF_XDP — the future bare-metal path (not shipped yet)
- VM deployment (cloud / virtio)
- Bare-metal deployment
- Rate limiting ([ratelimit])
- Snapshot ([snapshot])
- Snapshot in RAM (tmpfs) — faster restarts
- Full scrape (http.full_scrape)
- Raw BEP-15 over HTTP (POST /bep15, alias /udp)
- Securing the stats/admin endpoints
- Peer-IP masking
- Pulling stats into other systems
- Client IPs behind a proxy (http.proxy_ip_header)
- Client-supplied announce IP (protocol.allow_client_ip)
- Tuning checklist
Tsunami deployment guide — VMs and bare metal
Tsunami is designed to run everywhere — cloud VMs, containers, and bare metal — but the fastest packet paths need specific kernel/driver features. This guide maps each feature to what it requires and how to tune per environment. Nothing is required beyond a modern Linux kernel; everything below is opt-in.
Quick capability matrix
| Feature | Kernel | Privilege | Hardware | Notes |
|---|---|---|---|---|
| UDP tracker (io_uring) | ≥ 5.1 | none | any | default, works everywhere |
| HTTP / WebSocket / stats | any | none | any | plain TCP |
| Peer snapshot/restore | any | write to snapshot.path |
any | disk space ≈ 12 B/peer (v4) |
| Rate limiting | any | none | any | pure userspace |
af_packet RX backend |
any | CAP_NET_RAW (root) |
any NIC | lower burst loss, ~1.5× CPU |
| AF_XDP (future) | ≥ 5.4 | CAP_NET_ADMIN+CAP_NET_RAW |
native-XDP NIC | not yet shipped |
One-command install + OS tuning
scripts/install.sh does the whole setup — build, service user, systemd
units, config, sysctl tuning, optional host tuning:
sudo scripts/install.sh --tune --with-loadgen # build + install + tune
sudo systemctl enable --now tsunami # start it
Useful flags: --prefix, --config-dir, --state-dir, --user,
--binary/--no-build, --no-sysctl, --tune-persist (re-apply host
tuning at boot), --af-packet (file-cap for the AF_PACKET backend),
--enable/--start. Config lands at /etc/tsunami/tsunami.toml
(seed from tsunami.toml.example, never overwritten on reinstall) and
is group-readable only (0640 root:tsunami). The unit runs as the
tsunami user with AmbientCapabilities=CAP_NET_RAW (needed only if you
switch rx_backend to af_packet), Restart=always, LimitNOFILE=1M,
OOMScoreAdjust=-500, and ExecReload → SIGHUP (access-list reload).
scripts/tune.sh handles the host-level knobs the tracker can't set
itself — run it standalone any time:
sudo scripts/tune.sh --check # show current vs target, change nothing
sudo scripts/tune.sh # apply (reads /etc/tsunami/tune.conf)
sudo scripts/tune.sh --persist # re-apply at every boot
sudo scripts/tune.sh --set KEY=V # record + apply an option
It applies /etc/sysctl.d/99-tsunami.conf (installed by install.sh),
sets the CPU governor to performance, and sets Transparent Huge Pages
to madvise. /etc/tsunami/tune.conf options:
| Key | Default | Effect |
|---|---|---|
GOVERNOR |
performance |
cpufreq governor for all CPUs |
THP |
madvise |
always|madvise|never |
HUGEPAGES |
— | reserve N×2 MiB hugepages (only helps once the app can use them — see below) |
BUSY_POLL |
— | net.core.busy_poll/busy_read µs — NAPI busy-poll, trades a core for latency |
RPS_IFACE |
— | enable Receive Packet Steering on an iface (single-queue virtio) |
The script never touches NIC channels or IRQ affinity automatically —
ethtool -L resets the link. It prints the recommended commands instead;
see "Bare-metal deployment" below.
Kernel requirements
- io_uring backend (default): Linux ≥ 5.1 for
recvmsgmultishot + provided buffer rings. Tested on 6.12. If io_uring is unavailable the worker fails at startup — there is no silent fallback, so check the log. - Everything else is plain sockets/threads and works on any kernel.
Choosing network.rx_backend
io_uring (default) — lowest CPU, best for most deployments
Multishot recvmsg on a normal UDP socket with registered buffer rings and
pooled sendmsg. This is the right choice unless you see burst loss.
CQ-overflow resilience: the completion queue is sized ~16× the SQ
entries (16–64K, tunable via TSUNAMI_CQ_SIZE) and the RX buffer ring is
sized independently of the send pool, so under overload the kernel sheds
excess packets with ENOBUFS (counted in dropped) before completions
can be lost. If a CQ overflow still occurs, Tsunami detects it and rebuilds
the RX buffer ring in place (cancel multishot → drain → re-register →
resubmit) — no restart. Monitor cqe_overflow_total /
tsunami_cqe_overflow_total; sustained non-zero growth means you're
regularly overflowing and should raise TSUNAMI_CQ_SIZE or switch to
af_packet. TSUNAMI_DEBUG_REBUILD=1 forces one rebuild at startup to
validate the path on a new kernel.
af_packet — lower burst loss, higher CPU
PACKET_RX_RING kernel packet-mmap ring + PACKET_FANOUT_HASH fan-out
across workers. Parses Ethernet/IPv4/IPv6/UDP in userspace; replies go out
normal bound UDP sockets.
- Requires
CAP_NET_RAW— run as root or grant the capability (setcap cap_net_raw+ep ./tsunami). - Interface selection: auto-detected from the default route for
wildcard binds; override with
TSUNAMI_PACKET_IFACE=<name>(e.g.lofor loopback tests). - Trade-off: ~15% lower peak throughput and ~1.5× CPU vs io_uring in
our tests, but dramatically lower loss under flood because the kernel
ring buffers bursts that would overflow UDP socket buffers. Use it when
you see
dropped/ENOBUFSspikes or run behind bursty traffic. - Works on any NIC including virtio — it's the portable fast path.
AF_XDP — the future bare-metal path (not shipped yet)
True zero-copy XDP needs a physical NIC whose driver supports native XDP (mlx5, i40e, ice, bnxt, etc.). On virtio only generic SKB-mode XDP is possible, which gives little benefit. The full implementation (UMEM + RX/TX/fill/completion rings + XSKMAP eBPF redirect + netlink attach + L2/L3/L4 TX frame construction) is designed but not yet shipped — see the benchmark report. Do not expect AF_XDP gains on a VM.
VM deployment (cloud / virtio)
rx_backend = "io_uring"is the sane default.af_packetalso works if you can grantCAP_NET_RAW.- virtio_net has no native XDP — AF_XDP gains won't materialize.
- vCPUs are often oversubscribed: set
socket_workersto the allocated vCPU count, not the host's. - Snapshot file lands on whatever disk is mounted — size it for ~12 bytes/peer (v4) / ~24 bytes/peer (v6). At 50M peers ≈ 600 MB (v4).
- Watch
socket_recv_buffer: on a shared host, very large buffers can hurt neighbors; 16–32 MiB is a reasonable ceiling.
Bare-metal deployment
rx_backend = "io_uring"still fine;af_packetif you want burst absorption and can run privileged.- NUMA: pin workers to cores on the same NUMA node as the NIC
(
numactl --cpunodebind --membind), and sizeswarm_shardsso peer memory sits near the workers that touch it. - Hugepages: the peer store is a large
Vec; backing it with huge pages reduces TLB misses at 50M-peer scale. Configurevm.nr_hugepagesand, if needed, run withMAP_HUGETLB(future knob). - IRQ/RSS: enable NIC receive-side scaling so queues spread across
cores; with
af_packetfanout or multi-queue io_uring workers, spread is automatic, but checkethtool -l/-x. - AF_XDP becomes attractive here once shipped — that's where the
1M pps path pays off.
Rate limiting ([ratelimit])
Per-worker token buckets on announce + scrape (the store-mutating paths). Connects and malformed packets are never limited — they're the cheap handshake and dropping them just causes client retries anyway.
announce_per_sec/scrape_per_sec: per worker; global ceiling ≈ value ×socket_workers. Start generous (e.g. 2–5× your expected announce rate) and tighten if you see abuse.burst: bucket capacity as a multiple of the per-second rate.1.0allows a 1-second burst; raise to absorb legitimate re-announce synchronized waves (e.g. after a tracker restart).- Shed requests are counted in
*_rate_limited_total(stats API +/metrics) so you can alert on them.
Snapshot ([snapshot])
enable,path,interval_secs(0 = shutdown-only).- Format is compact binary (~12 B/peer v4, ~24 B/peer v6) written to
<path>.tmpthen atomically renamed — safe against torn writes. - Restore happens before workers bind; a 50M-peer snapshot adds startup latency (tens of seconds) while the index rebuilds. Announce retries cover the gap.
peer_idside-table is not restored (only present whenstore_peer_id=true); it repopulates on each peer's next announce.- Expired peers (
valid_untilpassed during downtime) are skipped on load.
Snapshot in RAM (tmpfs) — faster restarts
Important nuance: a snapshot buffer inside the tracker process cannot survive a restart — process RAM is freed on exit. But you can put the snapshot file on a RAM-backed filesystem so reads/writes run at memory speed while still surviving process restarts:
[snapshot]
path = "/dev/shm/tsunami.snap" # tmpfs = RAM-backed, survives restarts
/dev/shm (tmpfs) is RAM-backed and persists across process restarts —
it only disappears on machine reboot or explicit deletion. Measured on this
box: ~55K peers restored to "ready" in ~110 ms. Size tmpfs for the file
(~12 B/peer v4 → ~600 MB at 50M peers). For a snapshot that must also
survive a machine reboot, keep path on real disk — or write to both.
The restore cost is dominated by index rebuild (hash-inserting every
peer), not file I/O — read_snapshot_torrent pre-sizes each swarm's
HashMap/Vec from the recorded peer count to avoid rehash storms. Expect
roughly ~0.5–1M peers/sec restore ⇒ ~50–100 s for a full 50M-peer state.
For zero-downtime restarts (no gap at all), the real answer is running two tracker instances behind a load balancer with peer-state sync (livesync/cluster) — planned as future work; snapshots cover the stop-start case.
Full scrape (http.full_scrape)
GET /scrape with no info_hash params returns stats for every
tracked torrent in BEP-12 format — identical to opentracker's fullscrape
(d5:filesd 20:<hash> d8:complete…ee … ee). Intended for stats mirrors and
your website.
- HTTP only. UDP BEP-15 scrape is a per-hash-list request and the reply
is a single datagram — it cannot carry millions of torrents, and there's
no standard "dump all" action. Clients that need the full set should use
the HTTP endpoint (or the periodic
scrape_exportfile). - Cost: iterates all swarms once; fine for periodic pulls, don't hammer it.
Raw BEP-15 over HTTP (POST /bep15, alias /udp)
POST /bep15 accepts the exact binary request a UDP client would send
in the request body (Content-Type: application/octet-stream,
Content-Length required) and returns the same binary BEP-15 reply in the
response body. It supports connect / announce / scrape and reuses the same
connection-id validation and swarm store as native UDP — so UDP-protocol
tooling, scrapers, and clients on UDP-blocked networks can talk to the
tracker over HTTP(S) or through a TCP-only proxy.
Semantics match UDP:
200 OK+application/octet-streambody = the BEP-15 response datagram.204 No Content= the request was silently dropped (bad/expired connection id, malformed packet) — the HTTP equivalent of a UDP client receiving no reply.413if the body exceedshttp.max_request_bytes;405for non-POST.
Source-IP caveat: like UDP, the tracker keys connection ids and peer
identity to the connection's source IP. If you terminate TLS or proxy in
front of /bep15 (nginx, Cloudflare, a load balancer) without the
real-IP fix below, every peer will appear as the proxy's IP. See
"Client IPs behind a proxy" next.
One request per connection; send Connection: close or let the client
re-open — there's no keep-alive pipelining on this endpoint.
Securing the stats/admin endpoints
The public tracker endpoints (/announce, /scrape, /bep15, WebSocket)
must stay reachable by peers — they are never gated. But the operational
routes — /api/*, /metrics, /dashboard, / — expose peer IPs, client
fingerprints and internals, so on a public IP you should protect them.
Two independent knobs under [http]:
admin_user = "admin" # Basic-auth user (empty = no password)
admin_password = "change-me" # plaintext — protect the config file
admin_allowlist = ["10.0.0.0/8", "192.168.1.5"] # IPs/CIDRs that bypass auth
admin_user/admin_password→ HTTP Basic auth on all admin routes. Browsers prompt once and cache it; Prometheus usesbasic_authin the scrape config.admin_allowlist→ matching source IPs/CIDRs skip password auth entirely — use it for your Prometheus/Grafana/monitoring hosts and office IPs so they don't need credentials.- With only an allowlist (no user), non-listed IPs get
403; with a user set they get401+WWW-Authenticate. - Both empty → admin routes are open (fine on a private/internal bind).
If you terminate TLS at a reverse proxy, you can also just don't expose
18081-style ports publicly and let the proxy inject auth — Tsunami's own
gate is the simpler option when the port is directly reachable.
Peer-IP masking
/api/torrent/{hash} peer lists carry peer IPs. With
http.mask_peer_ips = true (default) they are masked to first+last parts —
IPv4 a.xxx.xxx.d, IPv6 s0:s1:xxxx:xxxx::s7 — so a public dashboard doesn't dump
full peer addresses. Tracker protocol responses (/announce, /scrape,
/bep15, WebSocket) always carry full IPs; peers need them to connect.
Set mask_peer_ips = false only if a trusted internal consumer needs the
full addresses.
Pulling stats into other systems
For periodic stats pulls (website logging, dashboards, cron jobs), four
formats cover most pipelines — all read-only GETs, all under the same
admin auth/allowlist gate as /api/stats:
| Endpoint | Format | Best for |
|---|---|---|
/api/stats |
JSON | structured ingestion (one snapshot) |
/api/stats.csv |
CSV group,metric,value |
tidy ingest into spreadsheets/DBs — every scalar stat, stable column names |
/api/stats.txt |
dotted.key=value lines |
shell/awk/grep, simple log appenders |
/api/timeseries.csv |
wide CSV, one row/sec | history logging — append to a file or TSDB |
/api/timeseries?seconds=N |
JSON samples | same data, structured |
/metrics |
Prometheus text | Prometheus/Grafana scrape |
Example cron pull to a flat file:
curl -s http://127.0.0.1:18081/api/stats.txt \
| sed "s/^/$(date +%s) /" >> stats.log
/api/timeseries.csv?seconds=3600 returns up to an hour of per-second
samples (requests/bytes per protocol, swarm counts, RSS, CPU) — poll it on
a slower cadence (e.g. every 5–15 min) and keep only the newest rows to
build your own retention window.
Client IPs behind a proxy (http.proxy_ip_header)
When Tsunami sits behind a reverse proxy / TLS terminator / load balancer,
the TCP peer address is the proxy's — without help, every peer collapses to
one IP. Set http.proxy_ip_header to the header your proxy uses for the
real client address:
[http]
proxy_ip_header = "X-Forwarded-For" # or "X-Real-IP", "CF-Connecting-IP", …
The leftmost address of that header becomes the client IP for /announce,
/scrape, /bep15 and WebSocket — used for connection-id validation, peer
identity and rate limiting. If the header is absent or unparseable the TCP
peer address is used, so direct (non-proxied) clients keep working.
Only enable this when the proxy always sets/overwrites the header. If clients can reach Tsunami directly and inject the header, they can forge their IP — restrict direct access to the proxy (firewall / bind to the proxy's address) or use PROXY-protocol-aware ingress.
UDP is unaffected — it has no proxy-header concept (use af_packet or a
PROXY-protocol LB if you need it there).
Client-supplied announce IP (protocol.allow_client_ip)
Privacy feature for clients that want to announce an address other than
their connection IP (e.g. a relay, VPN exit, or anycast anycast-fronted
endpoint). When enabled, the BEP-15 announce ip field (bytes 84..88) and
the BEP-3 ip= query param are honored as the peer's swarm address.
The real connection IP is still authoritative for everything that matters for abuse control: connection-id validation, rate limiting, and access-list decisions. So the tracker's numbers stay honest — peers, leechers/seeders and request rates all reflect real announces, not forged ones.
[protocol]
allow_client_ip = true
Off by default and should stay off for open trackers — it lets any
client inject arbitrary IPs into peer lists (swarm poisoning: fake peers,
polluted scrape results). Enable only for a peer population you trust, and
prefer pairing it with [access_list] so only approved info-hashes accept
these announces. The two features compose: proxy_ip_header gives the
real source IP; allow_client_ip lets a client then publish a different
swarm address on top of it.
Tuning checklist
Start with sudo scripts/install.sh --tune — it applies the sysctl drop-in
(net.core.rmem_max/wmem_max = 256 MiB so socket_recv_buffer up to
128 MiB isn't silently clamped, netdev_max_backlog = 250k, raised NAPI
budgets, somaxconn, swappiness=10), the performance governor and
THP=madvise. The table below is what to adjust in the config on top:
| Knob | VM | Bare metal |
|---|---|---|
rx_backend |
io_uring (or af_packet if privileged) |
io_uring; af_packet for burst absorption |
socket_workers |
= allocated vCPUs | = physical cores (mind NUMA) |
socket_recv_buffer |
16–32 MiB | 32–64 MiB |
ring_size |
2048–4096 | 4096–8192 |
TSUNAMI_CQ_SIZE |
leave default (auto ~16×) | raise to 32768–65536 under sustained flood |
swarm_shards |
128–256 | 256+ |
snapshot.interval_secs |
300 | 60–300 |
ratelimit.* |
set per expected load | set per expected load |