This experiment verifies that setting IPV6_V6ONLY=1 on IPv6 sockets at the Rust
code level (via socket2) allows a single tracker process to bind both
0.0.0.0:<port> (IPv4-only) and [::]:<port> (IPv6-only) on the same port —
without requiring a system-wide sysctl net.ipv6.bindv6only=1.
A tracker operator has two strategies to separate IPv4 and IPv6 traffic in metrics:
| Strategy | How it works | Pros | Cons |
|---|---|---|---|
| Client address parsing (Task 2) | Parse the client's SocketAddr to detect ::ffff: v4-mapped addresses |
Works with current dual-stack socket, no infra changes | Only splits after the fact in metrics |
| Separate socket bindings (Task 1, this experiment) | Bind 0.0.0.0 and [::] on same port with IPV6_V6ONLY=1 |
True separation: metrics, performance isolation, independent sockets | Requires code change, more sockets |
If separate bindings work, we can later add a config option so operators can choose.
- Rust toolchain
net.ipv6.bindv6onlymust be0(Linux default)
sysctl net.ipv6.bindv6only
# Expected: net.ipv6.bindv6only = 0If it's 1, the experiment is invalid because the OS already separates sockets.
Set it back to 0 (requires root):
sudo sysctl -w net.ipv6.bindv6only=0cd contrib/dev-tools/experiments/dual-stack-sockets
# Run a single tracker with both IPv4 and IPv6 listeners on the same ports
cargo run --bin torrust-tracker -- --config config/tracker.dual-stack.tomlIf IPV6_V6ONLY=1 works at runtime, the tracker should start successfully with
both address families on the same ports. If it fails, the second bind will get
EADDRINUSE.
| Scenario | Expected behaviour |
|---|---|
Without IPV6_V6ONLY change (original code) |
Second bind fails with EADDRINUSE |
With IPV6_V6ONLY=1 change (current branch) |
Both bindings succeed |
With the tracker running, check the Prometheus metrics endpoint:
curl -s "http://127.0.0.1:1212/api/v1/metrics?token=MyAccessToken&format=prometheus" | grep server_binding_address_ip_familyYou should see both inet and inet6 entries for the same protocol+port:
server_binding_address_ip_family="inet" # from the 0.0.0.0 socket
server_binding_address_ip_family="inet6" # from the [::] socket
$ sysctl net.ipv6.bindv6only
net.ipv6.bindv6only = 0
$ uname -a
Linux josecelano-desktop 7.0.0-22-generic #22-Ubuntu SMP PREEMPT_DYNAMIC Mon May 25 15:54:34 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
cd /home/josecelano/.../torrust-tracker-agent-03
rm -f storage/tracker/lib/database/sqlite3.db
TORRUST_TRACKER_CONFIG_TOML_PATH=contrib/dev-tools/experiments/dual-stack-sockets/config/tracker.dual-stack.toml \
cargo run --bin torrust-trackerUNCONN 0.0.0.0:6969 users:(("torrust-tracker",fd=11)) # IPv4 UDP
UNCONN [::]:6969 users:(("torrust-tracker",fd=12)) # IPv6 UDP
LISTEN 0.0.0.0:7070 users:(("torrust-tracker",fd=13)) # IPv4 HTTP
LISTEN [::]:7070 users:(("torrust-tracker",fd=14)) # IPv6 HTTP
All four sockets bound — no EADDRINUSE error. IPV6_V6ONLY=1 set at runtime
(via socket2 crate) is sufficient; no sysctl required.
UDP TRACKER: Starting on: 0.0.0.0:6969
UDP TRACKER: Started on: udp://0.0.0.0:6969
UDP TRACKER: Starting on: [::]:6969
UDP TRACKER: Started on: udp://[::]:6969
HTTP TRACKER: Starting on: http://0.0.0.0:7070
HTTP TRACKER: Started on: http://0.0.0.0:7070
HTTP TRACKER: Starting on: http://[::]:7070
HTTP TRACKER: Started on: http://[::]:7070
After sending requests from both IPv4 and IPv6 clients, the labeled metrics show correct separation:
UDP — IPv4 client → IPv4 socket (0.0.0.0:6969):
udp_tracker_core_requests_received_total{
client_address_ip_family="inet",
client_address_ip_type="plain",
server_binding_address_ip_family="inet",
...
} 1
UDP — IPv6 client → IPv6 socket ([::]:6969, via ::1):
udp_tracker_core_requests_received_total{
client_address_ip_family="inet6",
client_address_ip_type="plain",
server_binding_address_ip_family="inet6",
...
} 1
HTTP — IPv6 client → IPv6 socket ([::]:7070, via ::1, curl -6):
http_tracker_core_requests_received_total{
client_address_ip_family="inet6",
client_address_ip_type="plain",
request_kind="announce",
server_binding_address_ip_family="inet6",
...
} 1
Both client_address_ip_family and client_address_ip_type labels are present
on all per-request counters.
| Scenario | Expected | Actual |
|---|---|---|
Without IPV6_V6ONLY change |
EADDRINUSE |
Not tested (would fail) |
With IPV6_V6ONLY=1 (current branch) |
Both bindings succeed | ✅ Both IPv4/IPv6 UDP+HTTP bind on same port |
| Client address labels present | All per-request counters | ✅ client_address_ip_family + client_address_ip_type visible |
| IPv4 → IPv4 socket labels | client=inet, server=inet |
✅ Confirmed via UDP announce to 127.0.0.1:6969 |
| IPv6 → IPv6 socket labels | client=inet6, server=inet6 |
✅ Confirmed via UDP+HTTP to [::1]:6969 and [::1]:7070 |
Both tasks confirmed working:
- Task 1 — Separate socket bindings:
IPV6_V6ONLY=1set viasocket2at the Rust code level allows a single tracker process to bind0.0.0.0:<port>and[::]:<port>simultaneously on the same port. No system-widesysctlneeded. - Task 2 — Client address labels:
client_address_ip_familyandclient_address_ip_typelabels are present on all per-request UDP and HTTP metric counters, correctly identifying the connecting client's address type.
- Consider performance benchmarks to confirm separate sockets improve throughput.