Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# Changelog

## Unreleased

### Added

- BEP 7 multi-homed tracker announce. A host with more than one network — a
second NIC, a VPN tunnel, tethering, IPv4 plus IPv6 on different interfaces —
now announces to each HTTP and UDP tracker from every eligible local address,
each on a socket bound to that address, so the tracker lists and serves peers
for each of them. Before, only one primary IPv4 and one primary IPv6 address
announced. Eligible means the first usable address of each interface that is
up, not loopback, link-local, ULA/site-local or IPv4-mapped, at most four per
family (`config :elixir_torrent, :tracker, max_sources_per_family: 4`;
`multihomed_announce: false` restores the old behaviour). A host with one
address per family announces exactly as before.
- The added announces run concurrently (`max_concurrent_source_announces: 4`),
and one that has not answered 15 s (`source_grace_ms`) after another source
succeeded is abandoned, so a source the tracker cannot reach never holds back
the peers the others found. Each distinct source address carries the
`started`/`completed`/`stopped` event once; tier failover (BEP 12) and
retry/cooldown (BEP 31) still see one merged answer per tracker.
- Every announce logs one `[tracker_announce] source_result` debug line per
source: tracker, event, destination, family, role (primary/secondary), bound
source and result.

### Fixed

- BEP 15 `connection_id`s are cached per local address as well as local port.
Two announce sockets bound to different addresses can get the same ephemeral
port, and trackers tie the id to the client's address.
- Peers a tracker returns at any of our IPv4 addresses on our listen port are
recognised as ourselves, not only the primary one.

## 0.7.0 - 2026-10-18

A magnet release. When a magnet's metadata arrived, the download threw away every
Expand Down
3 changes: 1 addition & 2 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ partial, and what is still missing — the same table the maintainer works from.
| [BEP 4](https://www.bittorrent.org/beps/bep_0004.html) | Known number allocations | **Full** | Message IDs and reserved bits as used |
| [BEP 5](https://www.bittorrent.org/beps/bep_0005.html) | DHT | **Substantially Full** | KRPC over UDP with error 204 for unknown methods; k-buckets with two-strike health and ping-before-evict; iterative peer lookup and iterative-to-self bootstrap; token validation; NAT-friendly `implied_port`; BT PORT; persisted BEP 32 dual-stack routing. Remaining strict gap: a first-seen inbound-only query source is still promoted before response reachability is proven |
| [BEP 6](https://www.bittorrent.org/beps/bep_0006.html) | Fast extension | **Full** | Fast-negotiated handshake availability, `allowed_fast`, verified-piece suggestions, and exactly-one `piece`/`reject` handling; choke rejects queued non-allowed requests after the choke |
| [BEP 7](https://www.bittorrent.org/beps/bep_0007.html) | IPv6 tracker extension | **Partial** | Parses `peers6`; source-bound HTTP/UDP announce from one primary IPv4 and one primary global IPv6 address — full per-listen-address multi-homing remains |
| [BEP 7](https://www.bittorrent.org/beps/bep_0007.html) | IPv6 tracker extension | **Substantially Full** | Parses `peers6`; multi-homed HTTP/UDP announce: one source-bound announce per eligible local address and family — the primary address plus the first usable address of every other interface that is up (second NIC, VPN, tethering; not loopback, link-local, ULA/site-local or IPv4-mapped), at most 4 per family, discovered from the 30 s interface snapshot. Each distinct source carries the event once; sources run concurrently (4 at a time) and an added source that has not answered 15 s after the first success is abandoned; peers are merged and deduplicated into one result per tracker, so BEP 12/31 logic is unchanged. Remaining gaps: an added source is bound but not pinned to its interface (no `SO_BINDTODEVICE`), so on a host without source-based routing its packets can still leave through the default route; temporary IPv6 addresses beyond one per interface, and HTTP trackers whose name we could not resolve ourselves, announce from the primary only; scrape uses the primary only; not yet verified on a live multi-homed host |
| [BEP 9](https://www.bittorrent.org/beps/bep_0009.html) | Extension for Peers to Send Metadata Files | **Full** | Bencoded `ut_metadata` request/data/reject, 16 KiB pieces, multi-peer fetch, SHA-1 verification (magnet bootstrap); completed torrents serve metadata to magnet leechers |
| [BEP 10](https://www.bittorrent.org/beps/bep_0010.html) | Extension Protocol | **Full** | Bounded LTEP transport: non-blocking id-0 negotiation, per-peer one-byte `m` ids, additive live re-handshake/disable, unknown-id ignore, and validated extension registration |
| [BEP 11](https://www.bittorrent.org/beps/bep_0011.html) | Peer exchange (ut_pex) | **Partial** | Private-torrent isolation; IPv4/IPv6 flags and bounded codec; per-connection initial snapshots + 60 s deltas; source-owned drops; recent-peer supplement; rate/filter/retention bounds; BEP 40 ordering; magnet-fetch ingest. Live libtorrent/qBittorrent v4+v6 and private-traffic interop remains unverified |
Expand Down Expand Up @@ -65,7 +65,6 @@ peers that support `ut_metadata`.

| Priority | Item | Why it matters |
| --- | --- | --- |
| Medium | **BEP 7 — full multi-homed announce** | Announce separately from every eligible local listen address, beyond the current one primary address per family |
| Medium | **BEP 52 phase 6 — pure-v2 interop** | Verify the Phase 5 `.torrent` path against live libtorrent/qBittorrent peers, then add pure-v2 magnet and webseed support |

BEP 17 (HTTP seeding, Hoffman-style) is not planned — BEP 19 covers the web-seed
Expand Down
215 changes: 205 additions & 10 deletions lib/elixir_torrent/acceptor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,22 @@ defmodule Acceptor do
inet: [:inet.ip4_address()],
inet6: [non_neg_integer()]
}
@type announce_sources :: %{inet: [:inet.ip4_address()], inet6: [:inet.ip6_address()]}
@type ip_snapshot :: %{
inet: :inet.ip4_address() | nil,
inet6: :inet.ip6_address() | nil,
inet6_all: [:inet.ip6_address()],
multicast_interfaces: multicast_interfaces()
required(:inet) => :inet.ip4_address() | nil,
required(:inet6) => :inet.ip6_address() | nil,
required(:inet6_all) => [:inet.ip6_address()],
required(:multicast_interfaces) => multicast_interfaces(),
optional(:inet_all) => [:inet.ip4_address()],
optional(:announce_sources) => announce_sources()
}

# BEP 7 multi-homed announce: how many local addresses per family a tracker
# announce may leave from (the primary included). One per interface, so a
# privacy-extension host with five temporary IPv6 addresses on one NIC still
# counts as one source; the cap only bites on hosts with many interfaces.
@default_max_sources_per_family 4

# Cached snapshot of getifaddrs-derived addresses. Written by IpCache every
# 30s; readers hit :persistent_term (~constant time), with a direct compute
# fallback for the pre-boot / test path where IpCache is not running.
Expand Down Expand Up @@ -188,21 +197,138 @@ defmodule Acceptor do
|> Enum.filter(&is_tuple/1)
end)

v4_all = Enum.filter(ips, &global_ipv4?/1)
v6_all = Enum.filter(ips, &global_ipv6?/1)
v4 = List.first(v4_all)
v6 = List.first(v6_all)

%{
inet: Enum.find(ips, &global_ipv4?/1),
inet6: List.first(v6_all),
inet: v4,
inet6: v6,
inet_all: v4_all,
inet6_all: v6_all,
multicast_interfaces: multicast_interfaces_from(ifs)
multicast_interfaces: multicast_interfaces_from(ifs),
announce_sources: announce_sources_from(ifs, v4, v6)
}
end

def compute_all_global_ips({:error, reason}) do
Logger.warning("getifaddrs failed: #{inspect(reason)}")
%{inet: nil, inet6: nil, inet6_all: [], multicast_interfaces: %{inet: [], inet6: []}}

%{
inet: nil,
inet6: nil,
inet_all: [],
inet6_all: [],
multicast_interfaces: %{inet: [], inet6: []},
announce_sources: %{inet: [], inet6: []}
}
end

@doc """
The local addresses a tracker announce leaves from, per family (BEP 7).

The first entry of each list is the primary address (`primary_ips/0`), so a
host with one address per family announces exactly as it always has. Every
further entry is the first announce-eligible address of *another* interface
that is up — a second NIC, a VPN tunnel, tethering — capped at
`:max_sources_per_family` (default #{@default_max_sources_per_family}) under
`config :elixir_torrent, :tracker`. `multihomed_announce: false` there
reduces the lists to the primary addresses.

Read from the `IpCache` snapshot, so an interface that comes or goes shows up
within one refresh (30 s); nothing here holds a socket.
"""
@spec announce_sources() :: announce_sources()
def announce_sources do
snapshot = all_global_ips()

sources =
Map.get_lazy(snapshot, :announce_sources, fn ->
%{inet: List.wrap(snapshot.inet), inet6: List.wrap(snapshot.inet6)}
end)

config = Application.get_env(:elixir_torrent, :tracker, [])

limit =
if Keyword.get(config, :multihomed_announce, true),
do: max_sources_per_family(config),
else: 1

%{inet: Enum.take(sources.inet, limit), inet6: Enum.take(sources.inet6, limit)}
end

@spec max_sources_per_family(keyword()) :: pos_integer()
defp max_sources_per_family(config) do
case Keyword.get(config, :max_sources_per_family, @default_max_sources_per_family) do
n when is_integer(n) and n >= 1 -> n
_ -> @default_max_sources_per_family
end
end

# Pure so the rules run against a synthetic interface list. The primary
# address leads its family whatever interface it is on (that is the address
# every other subsystem advertises); then one address per remaining interface,
# in `getifaddrs` order. The primary is taken as given — a host whose only
# IPv6 is a ULA keeps announcing it as before — but an *additional* source
# must be one a public tracker can answer: a ULA, a deprecated site-local or
# an IPv4-mapped address is a source the tracker would record and nobody
# could reach.
@doc false
@spec announce_sources_from(
[{charlist(), keyword()}],
:inet.ip4_address() | nil,
:inet.ip6_address() | nil
) :: announce_sources()
def announce_sources_from(ifs, primary_v4, primary_v6) do
%{
inet: family_sources(ifs, primary_v4, &global_ipv4?/1),
inet6: family_sources(ifs, primary_v6, &extra_ipv6_source?/1)
}
end

@spec family_sources(
[{charlist(), keyword()}],
:inet.ip_address() | nil,
(:inet.ip_address() -> boolean())
) :: [:inet.ip_address()]
defp family_sources(_ifs, nil, _eligible?), do: []

defp family_sources(ifs, primary, eligible?) do
extras =
Enum.flat_map(ifs, fn {_ifname, props} ->
addresses = Enum.filter(Keyword.get_values(props, :addr), &is_tuple/1)

cond do
primary in addresses -> []
not announce_interface?(Keyword.get(props, :flags, [])) -> []
true -> List.wrap(Enum.find(addresses, eligible?))
end
end)

Enum.uniq([primary | extras])
end

# A loopback interface owns nothing a tracker could reach, and an interface
# that is down owns addresses no packet can leave from.
@spec announce_interface?(list()) :: boolean()
defp announce_interface?(flags), do: :up in flags and :loopback not in flags

@spec extra_ipv6_source?(:inet.ip_address()) :: boolean()
defp extra_ipv6_source?({s1, _s2, _s3, _s4, _s5, _s6, _s7, _s8} = ip) when is_integer(s1) do
case ip do
# fc00::/7 unique local, fec0::/10 deprecated site-local
_ when s1 in 0xFC00..0xFDFF -> false
_ when s1 in 0xFEC0..0xFEFF -> false
# ::ffff:0:0/96 IPv4-mapped and ::/96 IPv4-compatible
{0, 0, 0, 0, 0, 0xFFFF, _, _} -> false
{0, 0, 0, 0, 0, 0, _, _} -> false
_ -> global_ipv6?(ip)
end
end

defp extra_ipv6_source?(_ip), do: false

@doc false
@spec multicast_interfaces() :: multicast_interfaces()
def multicast_interfaces, do: all_global_ips().multicast_interfaces
Expand Down Expand Up @@ -494,6 +620,63 @@ defmodule Acceptor do
end
end

@doc """
Whether a socket bound to `source` has a route to `dest`.

The question `announce_source_ip/3` cannot ask: it probes the route the
*stack* would choose, while an additional BEP 7 source is bound on purpose
to an address that is not the stack's choice. A datagram socket bound to
`source` is `connect`ed to `dest` — a route lookup, **no packet is sent**.
It fails when the address has gone (interface removed since the last
`IpCache` refresh), when the family is off, or when no route leaves from that
source. A loopback destination is reachable only from a loopback source, and
a loopback source reaches nothing else, whatever the stack would allow.
"""
@spec source_reaches?(:inet.ip_address(), :inet.ip_address(), :inet.port_number()) ::
boolean()
def source_reaches?(source, dest, port)
when tuple_size(source) in [4, 8] and tuple_size(source) == tuple_size(dest) do
if loopback_ip?(source) == loopback_ip?(dest) do
bound_route_probe(source, dest, port)
else
false
end
end

def source_reaches?(_source, _dest, _port), do: false

@spec bound_route_probe(:inet.ip_address(), :inet.ip_address(), :inet.port_number()) ::
boolean()
defp bound_route_probe(source, dest, port) do
family = if tuple_size(source) == 4, do: :inet, else: :inet6

case :gen_udp.open(0, [:binary, family, {:active, false}, {:ip, source}]) do
{:ok, socket} ->
try do
case :gen_udp.connect(socket, dest, probe_port(port)) do
:ok ->
true

{:error, reason} ->
Logger.debug(
"[acceptor] source_route_probe_failed source=#{format_ip(source)} dest=#{format_ip(dest)} reason=#{inspect(reason)}"
)

false
end
after
:gen_udp.close(socket)
end

{:error, reason} ->
Logger.debug(
"[acceptor] source_route_probe_no_socket source=#{format_ip(source)} reason=#{inspect(reason)}"
)

false
end
end

@spec same_family?(:inet.ip_address(), :inet.ip_address()) :: boolean()
defp same_family?(a, b) when is_tuple(a) and is_tuple(b),
do: tuple_size(a) == tuple_size(b)
Expand Down Expand Up @@ -530,10 +713,22 @@ defmodule Acceptor do
defp this_host_ip?({0, 0, 0, 0, 0, 0, 0, 1}), do: true

defp this_host_ip?(ip) do
%{inet: v4, inet6_all: v6_all} = all_global_ips()
ip == v4 or ip in v6_all
%{inet6_all: v6_all} = snapshot = all_global_ips()
ip in all_ipv4(snapshot) or ip in v6_all
end

@doc """
Every global IPv4 address of this host, the primary first.

A multi-homed announce (`announce_sources/0`) makes trackers echo each of
them back as a peer, so self-connection checks need all of them, not just
`primary_ips/0`. Snapshots written before `inet_all` existed fall back to the
primary address.
"""
@spec all_ipv4(ip_snapshot()) :: [:inet.ip4_address()]
def all_ipv4(snapshot \\ all_global_ips()),
do: Map.get_lazy(snapshot, :inet_all, fn -> List.wrap(snapshot.inet) end)

@doc false
@spec ipv4_binary() :: <<_::32>> | nil
def ipv4_binary do
Expand Down
2 changes: 1 addition & 1 deletion lib/elixir_torrent/acceptor/connection/handshakes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ defmodule Acceptor.Connection.Handshakes do
end

defp global_ip_at_listen_port?({a, b, c, d}) do
Acceptor.all_global_ips().inet == {a, b, c, d}
{a, b, c, d} in Acceptor.all_ipv4()
end

defp global_ip_at_listen_port?({a, b, c, d, _, _, _, _}) do
Expand Down
16 changes: 9 additions & 7 deletions lib/elixir_torrent/peer_discovery/connection_ids.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ defmodule PeerDiscovery.ConnectionIds do
@spec handle_cast({:invalidate, port(), :inet.ip_address(), :inet.port_number()}, State.t()) ::
{:noreply, State.t()}
def handle_cast({:invalidate, socket, ip, port}, %State{} = state) do
{:ok, local_port} = :inet.port(socket)
key = {ip, port, local_port}
key = connection_id_cache_key(socket, ip, port)
{:noreply, update_in(state, [Access.key!(:ids)], &Map.delete(&1, key))}
end

@spec handle_call(term(), GenServer.from(), State.t()) ::
{:reply, {:ok, Tracker.connection_id()} | Tracker.Error.t() | :error, State.t()}
| {:noreply, State.t()}
def handle_call([socket, ip, port], from, %State{} = state) do
# Some UDP trackers appear to bind connection_id validity to the client's source port.
# We open new UDP sockets over time, so cache connection_ids per (tracker endpoint, local port).
# Some UDP trackers appear to bind connection_id validity to the client's source port,
# and every tracker binds it to the source address (BEP 15 derives it from the client's
# IP). We open new UDP sockets over time, and a BEP 7 multi-homed announce binds one per
# local address — two of them may get the same ephemeral port — so cache connection_ids
# per (tracker endpoint, local address, local port).
key = connection_id_cache_key(socket, ip, port)

case Map.fetch(state.ids, key) do
Expand All @@ -71,10 +73,10 @@ defmodule PeerDiscovery.ConnectionIds do
end

@spec connection_id_cache_key(port(), :inet.ip_address(), :inet.port_number()) ::
{:inet.ip_address(), :inet.port_number(), :inet.port_number()}
{:inet.ip_address(), :inet.port_number(), :inet.ip_address(), :inet.port_number()}
defp connection_id_cache_key(socket, ip, port) do
{:ok, local_port} = :inet.port(socket)
{ip, port, local_port}
{:ok, {local_ip, local_port}} = :inet.sockname(socket)
{ip, port, local_ip, local_port}
end

@spec enqueue_connection_id_waiter(State.t(), term(), GenServer.from()) :: State.t()
Expand Down
Loading