diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86de92f..6740b88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/PROTOCOL.md b/PROTOCOL.md
index f8b835c..bea0f49 100644
--- a/PROTOCOL.md
+++ b/PROTOCOL.md
@@ -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 |
@@ -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
diff --git a/lib/elixir_torrent/acceptor.ex b/lib/elixir_torrent/acceptor.ex
index 7f76cb0..9beade0 100644
--- a/lib/elixir_torrent/acceptor.ex
+++ b/lib/elixir_torrent/acceptor.ex
@@ -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.
@@ -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
@@ -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)
@@ -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
diff --git a/lib/elixir_torrent/acceptor/connection/handshakes.ex b/lib/elixir_torrent/acceptor/connection/handshakes.ex
index d86de1c..798c39d 100644
--- a/lib/elixir_torrent/acceptor/connection/handshakes.ex
+++ b/lib/elixir_torrent/acceptor/connection/handshakes.ex
@@ -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
diff --git a/lib/elixir_torrent/peer_discovery/connection_ids.ex b/lib/elixir_torrent/peer_discovery/connection_ids.ex
index f8d6d54..adfe4a0 100644
--- a/lib/elixir_torrent/peer_discovery/connection_ids.ex
+++ b/lib/elixir_torrent/peer_discovery/connection_ids.ex
@@ -45,8 +45,7 @@ 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
@@ -54,8 +53,11 @@ defmodule PeerDiscovery.ConnectionIds do
{: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
@@ -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()
diff --git a/lib/elixir_torrent/tracker.ex b/lib/elixir_torrent/tracker.ex
index be44929..1832f0c 100644
--- a/lib/elixir_torrent/tracker.ex
+++ b/lib/elixir_torrent/tracker.ex
@@ -3,7 +3,7 @@ defmodule Tracker do
HTTP and UDP (BEP 15) tracker announce/scrape client with multi-homed source IP selection.
"""
- alias __MODULE__.{Error, Response, UDP}
+ alias __MODULE__.{Error, Fanout, Response, UDP}
require Logger
@@ -61,11 +61,29 @@ defmodule Tracker do
def model_not_ready_error,
do: %Error{reason: :model_not_ready, retry_in: @model_not_ready_retry_sec}
+ # `announce_sources` replaces `Acceptor.announce_sources/0` for one request
+ # (tests inject a synthetic address list through it) and `source_grace_ms`
+ # the `Tracker.Fanout` grace window; `source_role` is set internally on the
+ # requests a multi-homed announce adds.
@type request_opts :: [
max_udp_attempts: 0..8,
- http_timeout_ms: pos_integer()
+ http_timeout_ms: pos_integer(),
+ announce_sources: Acceptor.announce_sources(),
+ source_grace_ms: non_neg_integer(),
+ source_role: Fanout.role()
]
+ # One announce of a multi-homed request: who it is for (`:primary` is the
+ # announce the engine always made, `:secondary` one BEP 7 adds per further
+ # local address), the family, the tracker address (`nil` when the HTTP path
+ # did not resolve it) and the source to bind (`nil` = let the route choose).
+ @type source_job ::
+ {Fanout.role(), :inet | :inet6, :inet.ip_address() | nil, :inet.ip_address() | nil}
+
+ # What a `source_result` log line names: the tracker, and a thunk for the
+ # event so that it is only worked out when debug logging is on.
+ @type log_label :: {String.t(), (-> String.t())}
+
@doc false
@spec fast_fail_request_opts() :: request_opts()
def fast_fail_request_opts do
@@ -121,14 +139,22 @@ defmodule Tracker do
defp udp_announce_responses(host, port, hash, stats, opts) do
case resolve_hosts(host) do
{:ok, hosts} ->
- source_ips = Acceptor.primary_ips()
-
- responses =
- hosts
- |> Enum.filter(&udp_announceable?(&1, source_ips))
- |> Enum.map(&udp_announce_on_host(&1, source_ips, port, hash, stats, opts))
+ sources = announce_sources(opts)
- merge_http_announces(responses)
+ hosts
+ |> Enum.filter(&udp_announceable?(&1, sources))
+ |> Enum.map(&udp_source_jobs(&1, sources, port))
+ |> order_source_jobs()
+ |> run_source_jobs(
+ {"udp://#{host}:#{port}", fn -> event_label(hash, stats) end},
+ hash,
+ opts,
+ fn job ->
+ udp_announce_job(job, port, hash, stats, opts)
+ end
+ )
+ |> Enum.map(&elem(&1, 1))
+ |> merge_http_announces()
{:error, reason} ->
# resolve_hosts failures are DNS-class (:nxdomain / timeout / …). Dead
@@ -144,27 +170,41 @@ defmodule Tracker do
# family to bind — or when it is loopback, which needs no source of ours at
# all and would otherwise be skipped on a machine that is offline or has no
# global address of that family.
- @spec udp_announceable?({:inet.ip_address(), :inet | :inet6}, map()) :: boolean()
- defp udp_announceable?({ip, family}, source_ips),
- do: Acceptor.loopback_ip?(ip) or Map.get(source_ips, family) != nil
-
- @spec udp_announce_on_host(
+ @spec udp_announceable?({:inet.ip_address(), :inet | :inet6}, Acceptor.announce_sources()) ::
+ boolean()
+ defp udp_announceable?({ip, family}, sources),
+ do: Acceptor.loopback_ip?(ip) or primary_source(sources, family) != nil
+
+ # BEP 7 wants the tracker to see the datagram arrive from the address we
+ # announce, but a bound source is only valid if the route to *this*
+ # destination leaves through an interface that owns it. For the primary
+ # address `announce_source_ip/3` asks the routing table (see `Acceptor`); it
+ # drops the bind for loopback and swaps in the routed address when our
+ # preferred one lives on an interface that does not reach the tracker (VPN,
+ # second NIC, tethering). Every further address of the family is added by
+ # `secondary_source_jobs/5`.
+ @spec udp_source_jobs(
{:inet.ip_address(), :inet | :inet6},
- map(),
+ Acceptor.announce_sources(),
+ :inet.port_number()
+ ) :: [source_job()]
+ defp udp_source_jobs({ip, family}, sources, port) do
+ source_ip = Acceptor.announce_source_ip(ip, port, primary_source(sources, family))
+
+ [
+ {:primary, family, ip, source_ip}
+ | secondary_source_jobs(family, ip, port, source_ip, extra_sources(sources, family))
+ ]
+ end
+
+ @spec udp_announce_job(
+ source_job(),
:inet.port_number(),
Torrent.hash(),
stats(),
request_opts()
) :: Response.t() | Error.t()
- defp udp_announce_on_host({ip, family}, source_ips, port, hash, stats, opts) do
- # BEP 7 wants the tracker to see the datagram arrive from the address we
- # announce, but a bound source is only valid if the route to *this*
- # destination leaves through an interface that owns it. `announce_source_ip/3`
- # asks the routing table (see `Acceptor`); it drops the bind for loopback
- # and swaps in the routed address when our preferred one lives on an
- # interface that does not reach the tracker (VPN, second NIC, tethering).
- source_ip = Acceptor.announce_source_ip(ip, port, Map.get(source_ips, family))
-
+ defp udp_announce_job({:primary, family, ip, source_ip}, port, hash, stats, opts) do
source_ip
|> udp_announce_bound({ip, family}, port, hash, stats, opts)
|> retry_unbound_source(source_ip, fn ->
@@ -172,6 +212,167 @@ defmodule Tracker do
end)
end
+ # No unbound retry for an added source: unbound is the primary's route, which
+ # the primary job already announces from. Retrying would send the same event
+ # twice from one address.
+ defp udp_announce_job({:secondary, family, ip, source_ip}, port, hash, stats, opts) do
+ udp_announce_bound(
+ source_ip,
+ {ip, family},
+ port,
+ hash,
+ stats,
+ Keyword.put(opts, :source_role, :secondary)
+ )
+ end
+
+ # ---------------------------------------------------------------------------
+ # BEP 7 multi-homed announce
+ #
+ # Each tracker address gets one announce per local source: the primary
+ # address of the family (exactly the announce made before multi-homing) plus
+ # one per further eligible address from `Acceptor.announce_sources/0`. Every
+ # source is a separate peer endpoint to the tracker, so each one carries the
+ # request's event (`started`/`completed`/`stopped`) once — a `stopped` that
+ # skipped an address would leave it listed until the tracker times it out.
+ # Two sources that would leave from the same address are one source: the
+ # tracker cannot tell them apart, and the event would reach it twice.
+ # Whatever the source count, `PeerDiscovery.Announce` sees one merged result
+ # per tracker URL, so its event bookkeeping, BEP 31 retry/cooldown and BEP 12
+ # tier logic run once per tracker, as before.
+ # ---------------------------------------------------------------------------
+
+ @spec announce_sources(request_opts()) :: Acceptor.announce_sources()
+ defp announce_sources(opts) do
+ case Keyword.fetch(opts, :announce_sources) do
+ {:ok, %{} = sources} -> Map.merge(%{inet: [], inet6: []}, sources)
+ :error -> Acceptor.announce_sources()
+ end
+ end
+
+ @spec primary_source(Acceptor.announce_sources(), :inet | :inet6) :: :inet.ip_address() | nil
+ defp primary_source(sources, family), do: List.first(Map.get(sources, family, []))
+
+ @spec extra_sources(Acceptor.announce_sources(), :inet | :inet6) :: [:inet.ip_address()]
+ defp extra_sources(sources, family), do: Enum.drop(Map.get(sources, family, []), 1)
+
+ # An added source is announced only to a resolved tracker address it has a
+ # route to (`Acceptor.source_reaches?/3`, no packet sent), and only when it
+ # would not leave from the address the primary job already uses. An
+ # unbound primary leaves from whatever the route picks; ask the route which
+ # address that is, so a source equal to it is not announced twice.
+ @spec secondary_source_jobs(
+ :inet | :inet6,
+ :inet.ip_address() | nil,
+ :inet.port_number(),
+ :inet.ip_address() | nil,
+ [:inet.ip_address()]
+ ) :: [source_job()]
+ defp secondary_source_jobs(_family, nil, _port, _primary_source, _extras), do: []
+ defp secondary_source_jobs(_family, _dest, _port, _primary_source, []), do: []
+
+ defp secondary_source_jobs(family, dest, port, primary_source, extras) do
+ taken = primary_source || Acceptor.route_source_ip(dest, port)
+
+ extras
+ |> Enum.reject(&(&1 == taken))
+ |> Enum.uniq()
+ |> Enum.filter(&Acceptor.source_reaches?(&1, dest, port))
+ |> Enum.map(&{:secondary, family, dest, &1})
+ end
+
+ # Primaries first, in the order they were always sent; secondaries after.
+ # `merge_http_announces/1` prefers the first error when every announce
+ # failed, and a primary's error is the tracker's real answer, so callers
+ # merge the primaries' results ahead of the secondaries'.
+ @spec order_source_jobs([[source_job()]]) :: [source_job()]
+ defp order_source_jobs(jobs_per_dest) do
+ {primaries, secondaries} =
+ jobs_per_dest
+ |> List.flatten()
+ |> Enum.split_with(&match?({:primary, _family, _dest, _source}, &1))
+
+ primaries ++ secondaries
+ end
+
+ # A single-homed host (no secondary job) runs its announces one after
+ # another, exactly as before multi-homing. Otherwise `Tracker.Fanout` runs
+ # them concurrently, so a dead source never holds back the others.
+ @spec run_source_jobs(
+ [source_job()],
+ log_label(),
+ Torrent.hash(),
+ request_opts(),
+ (source_job() -> Response.t() | Error.t())
+ ) :: [{source_job(), Response.t() | Error.t()}]
+ defp run_source_jobs(jobs, tracker, hash, opts, fun) do
+ results =
+ if Enum.all?(jobs, &match?({:primary, _family, _dest, _source}, &1)) do
+ Enum.map(jobs, fun)
+ else
+ Fanout.run(Enum.map(jobs, &{elem(&1, 0), &1}), fun, fanout_opts(opts))
+ end
+
+ pairs = Enum.zip(jobs, results)
+ log_source_results(pairs, tracker, hash)
+ pairs
+ end
+
+ # A malformed setting falls back to the default: a concurrency of 0 would
+ # start nothing and wait forever.
+ @spec fanout_opts(request_opts()) :: keyword()
+ defp fanout_opts(opts) do
+ config = Application.get_env(:elixir_torrent, :tracker, [])
+ max_concurrency = Keyword.get(config, :max_concurrent_source_announces)
+ grace_ms = Keyword.get(opts, :source_grace_ms, Keyword.get(config, :source_grace_ms))
+
+ [
+ max_concurrency:
+ if(is_integer(max_concurrency) and max_concurrency >= 1,
+ do: max_concurrency,
+ else: Fanout.default_max_concurrency()
+ ),
+ grace_ms:
+ if(is_integer(grace_ms) and grace_ms >= 0, do: grace_ms, else: Fanout.default_grace_ms())
+ ]
+ end
+
+ # One debug line per source announce: the evidence for "which addresses did
+ # this announce leave from", on a single- and a multi-homed host alike.
+ @spec log_source_results(
+ [{source_job(), Response.t() | Error.t()}],
+ log_label(),
+ Torrent.hash()
+ ) :: :ok
+ defp log_source_results(pairs, {tracker, event}, hash) do
+ Enum.each(pairs, fn {{role, family, dest, source}, result} ->
+ Logger.debug(fn ->
+ "[tracker_announce] source_result hash=#{Torrent.hex_encoded_hash(hash)} tracker=#{tracker} event=#{event.()} dest=#{format_dest(dest)} family=#{family} role=#{role} source=#{format_source(source)} #{format_source_result(result)}"
+ end)
+ end)
+ end
+
+ # For the log only. An `:auto` request reads its event from the model per
+ # announce; read it once more here rather than thread it back out.
+ @spec event_label(Torrent.hash(), stats()) :: String.t()
+ defp event_label(hash, stats) do
+ case resolve_stats(hash, stats) do
+ {:ok, {_uploaded, _downloaded, _left, event}} -> event_name(event)
+ :skip -> "unknown"
+ end
+ end
+
+ @spec event_name(0..3) :: String.t()
+ defp event_name(event), do: Torrent.event_to_string(event) || "none"
+
+ @spec format_dest(:inet.ip_address() | nil) :: String.t()
+ defp format_dest(nil), do: "unresolved"
+ defp format_dest(ip), do: Acceptor.format_ip(ip)
+
+ @spec format_source_result(Response.t() | Error.t()) :: String.t()
+ defp format_source_result(%Response{peers: peers}), do: "result=ok peers=#{length(peers)}"
+ defp format_source_result(%Error{reason: reason}), do: "result=error reason=#{inspect(reason)}"
+
@spec udp_announce_bound(
:inet.ip_address() | nil,
{:inet.ip_address(), :inet | :inet6},
@@ -507,40 +708,86 @@ defmodule Tracker do
url = append_query(announce, query)
- %{inet: ip4, inet6: ip6, inet6_all: v6_all} = Acceptor.all_global_ips()
+ %{inet6_all: v6_all} = Acceptor.all_global_ips()
+ sources = announce_sources(opts)
{tracker_families, dest_ctx} = http_tracker_endpoints(announce)
- ipv4_announce = if MapSet.member?(tracker_families, :inet), do: ip4
- v6_announces =
- if MapSet.member?(tracker_families, :inet6), do: Acceptor.announcable_ipv6(), else: []
-
- if ip4 || ip6 do
+ if sources.inet != [] or sources.inet6 != [] do
Logger.debug(
- "[tracker_announce] http_endpoints hash=#{Torrent.hex_encoded_hash(hash)} ipv4=#{if ip4, do: Acceptor.format_ip(ip4), else: "none"} ipv6_announce=#{Enum.map_join(v6_announces, ",", &Acceptor.format_ip/1)} ipv6_all=#{Enum.map_join(v6_all, ",", &Acceptor.format_ip/1)}"
+ "[tracker_announce] http_endpoints hash=#{Torrent.hex_encoded_hash(hash)} ipv4=#{format_sources(sources.inet)} ipv6_announce=#{format_sources(sources.inet6)} ipv6_all=#{Enum.map_join(v6_all, ",", &Acceptor.format_ip/1)}"
)
end
- []
- |> maybe_http_announce(url, :inet, ipv4_announce, dest_ctx, opts)
- |> http_announce_ipv6(url, v6_announces, dest_ctx, opts)
+ [:inet, :inet6]
+ |> Enum.filter(&MapSet.member?(tracker_families, &1))
+ |> Enum.map(&http_source_jobs(&1, sources, dest_ctx))
+ |> order_source_jobs()
+ |> run_source_jobs(
+ {http_tracker_label(announce), fn -> event_name(event) end},
+ hash,
+ opts,
+ fn job ->
+ http_announce_job(job, url, opts)
+ end
+ )
+ |> merge_http_source_results()
+ end
+
+ # The IPv4 announce has always gone out first and the merge has always seen
+ # the IPv6 result first; keep both, then the secondaries.
+ @spec merge_http_source_results([{source_job(), Response.t() | Error.t()}]) ::
+ Response.t() | Error.t()
+ defp merge_http_source_results(pairs) do
+ {primaries, secondaries} =
+ Enum.split_with(pairs, &match?({{:primary, _family, _dest, _source}, _result}, &1))
+
+ (Enum.reverse(primaries) ++ secondaries)
+ |> Enum.map(&elem(&1, 1))
|> merge_http_announces()
end
- # BEP 7 announces once per announceable IPv6 address, each bound to that
- # address (subject to the route check in `http_source_bind/3`).
- @spec http_announce_ipv6(
- list(Response.t() | Error.t()),
- binary(),
- [:inet.ip6_address()],
- dest_ctx(),
- request_opts()
- ) :: list(Response.t() | Error.t())
- defp http_announce_ipv6(acc, url, v6_announces, dest_ctx, opts) do
- Enum.reduce(v6_announces, acc, fn ip6_addr, announces ->
- [http_announce(url, :inet6, http_source_bind(dest_ctx, :inet6, ip6_addr), opts) | announces]
- end)
+ @spec http_source_jobs(:inet | :inet6, Acceptor.announce_sources(), dest_ctx()) ::
+ [source_job()]
+ defp http_source_jobs(family, sources, {dests, port, _loopback?} = dest_ctx) do
+ case primary_source(sources, family) do
+ nil ->
+ []
+
+ primary ->
+ dest = Map.get(dests, family)
+ source = http_source_bind(dest_ctx, family, primary)
+
+ [
+ {:primary, family, dest, source}
+ | secondary_source_jobs(family, dest, port, source, extra_sources(sources, family))
+ ]
+ end
+ end
+
+ @spec http_announce_job(source_job(), binary(), request_opts()) :: Response.t() | Error.t()
+ defp http_announce_job({:primary, family, _dest, source}, url, opts),
+ do: http_announce(url, family, source, opts)
+
+ # As on UDP: an added source never retries unbound.
+ defp http_announce_job({:secondary, family, _dest, source}, url, opts),
+ do: http_announce_bound(url, family, source, opts)
+
+ # Host and port only: the query of a private tracker carries its passkey.
+ @spec http_tracker_label(binary()) :: String.t()
+ defp http_tracker_label(announce) do
+ case URI.parse(announce) do
+ %URI{scheme: scheme, host: host, port: port} when is_binary(host) ->
+ "#{scheme}://#{host}:#{port}"
+
+ _ ->
+ "http"
+ end
end
+ @spec format_sources([:inet.ip_address()]) :: String.t()
+ defp format_sources([]), do: "none"
+ defp format_sources(ips), do: Enum.map_join(ips, ",", &Acceptor.format_ip/1)
+
@spec append_query(binary(), binary()) :: binary()
defp append_query(url, encoded_query) do
uri = URI.parse(url)
@@ -734,21 +981,6 @@ defmodule Tracker do
defp connection_id_expired?(_), do: false
- @spec maybe_http_announce(
- list(Response.t() | Error.t()),
- binary(),
- :inet | :inet6,
- any(),
- dest_ctx(),
- request_opts()
- ) ::
- list(Response.t() | Error.t())
- defp maybe_http_announce(acc, _url, _family, nil, _dest_ctx, _opts), do: acc
-
- defp maybe_http_announce(acc, url, family, ip, dest_ctx, opts) do
- [http_announce(url, family, http_source_bind(dest_ctx, family, ip), opts) | acc]
- end
-
# `source == nil` from here down means "no source bind"; the diagnostics
# render it as `auto` rather than printing an address the socket is not using.
@spec http_announce(binary(), :inet | :inet6, :inet.ip_address() | nil, request_opts()) ::
@@ -1331,7 +1563,10 @@ defmodule Tracker do
defp do_udp_announce(socket, ip, port, connection_id, hash, announce_stats, family, opts) do
transaction_id = generate_transaction_id()
- packet = encode_udp_announce(connection_id, transaction_id, hash, announce_stats, family)
+
+ packet =
+ encode_udp_announce(connection_id, transaction_id, hash, announce_stats, family, opts)
+
max_attempts = Keyword.get(opts, :max_udp_attempts, @udp_max_backoff_attempt)
request_opts =
@@ -1398,7 +1633,7 @@ defmodule Tracker do
}) do
case fetch_connection_id(socket, ip, port, opts) do
{:ok, id} ->
- {:ok, encode_udp_announce(id, transaction_id, hash, announce_stats, family)}
+ {:ok, encode_udp_announce(id, transaction_id, hash, announce_stats, family, opts)}
%Error{} = error ->
error
@@ -1560,16 +1795,18 @@ defmodule Tracker do
<<_::32>>,
Torrent.hash(),
{non_neg_integer(), non_neg_integer(), non_neg_integer(), 0..3},
- :inet | :inet6
+ :inet | :inet6,
+ request_opts()
) :: iodata()
defp encode_udp_announce(
connection_id,
transaction_id,
hash,
{uploaded, downloaded, left, event},
- family
+ family,
+ opts
) do
- ip_field = udp_announce_ip_field(family)
+ ip_field = udp_announce_ip_field(family, Keyword.get(opts, :source_role, :primary))
UDP.encode_announce(connection_id, transaction_id, hash, Peer.id(),
uploaded: uploaded,
@@ -1584,10 +1821,13 @@ defmodule Tracker do
end
# BEP 15 § Announce — IPv6 clients set ip_field to 0; tracker learns address from packet source.
- @spec udp_announce_ip_field(:inet | :inet6) :: <<_::32>>
- defp udp_announce_ip_field(:inet6), do: <<0::32>>
+ # An added BEP 7 source does the same on IPv4: the primary address in the
+ # field would make the tracker record the primary for this announce too.
+ @spec udp_announce_ip_field(:inet | :inet6, Fanout.role()) :: <<_::32>>
+ defp udp_announce_ip_field(:inet6, _role), do: <<0::32>>
+ defp udp_announce_ip_field(:inet, :secondary), do: <<0::32>>
- defp udp_announce_ip_field(:inet) do
+ defp udp_announce_ip_field(:inet, :primary) do
case Acceptor.ipv4_binary() do
<> -> <>
_ -> <<0::32>>
@@ -1600,10 +1840,11 @@ defmodule Tracker do
<<_::32>>,
Torrent.hash(),
{non_neg_integer(), non_neg_integer(), non_neg_integer(), 0..3},
- :inet | :inet6
+ :inet | :inet6,
+ request_opts()
) :: iodata()
- def encode_udp_announce_for_test(connection_id, transaction_id, hash, stats, family) do
- encode_udp_announce(connection_id, transaction_id, hash, stats, family)
+ def encode_udp_announce_for_test(connection_id, transaction_id, hash, stats, family, opts \\ []) do
+ encode_udp_announce(connection_id, transaction_id, hash, stats, family, opts)
end
# HTTP trackers typically return:
diff --git a/lib/elixir_torrent/tracker/fanout.ex b/lib/elixir_torrent/tracker/fanout.ex
new file mode 100644
index 0000000..0e801b3
--- /dev/null
+++ b/lib/elixir_torrent/tracker/fanout.ex
@@ -0,0 +1,156 @@
+defmodule Tracker.Fanout do
+ @moduledoc false
+ # BEP 7 multi-homed announce runs one tracker request per (tracker address,
+ # local source). They must not wait on each other: a source whose datagrams
+ # are dropped upstream (a VPN that does not route the tracker, a second NIC
+ # without a default route) can sit on the full BEP 15 ladder or a 5-minute
+ # HTTP timeout, and the peers the other sources already have would wait with
+ # it. So the jobs run concurrently, bounded, and:
+ #
+ # - a `:primary` job — the announce the engine made before multi-homing — is
+ # always run to its own end; its timeouts and retries are unchanged;
+ # - a `:secondary` job gets `grace_ms` after the first `%Tracker.Response{}`
+ # of the request. Still running then, it is killed; not started yet, it is
+ # skipped. Both come back as `%Tracker.Error{reason: :source_abandoned}`.
+ #
+ # A job that raises or exits becomes a `{:source_crashed, _}` error for that
+ # job alone. Results come back in job order.
+
+ alias Tracker.{Error, Response}
+
+ @type role :: :primary | :secondary
+ @type result :: Response.t() | Error.t()
+
+ @default_max_concurrency 4
+ @default_grace_ms 15_000
+
+ @spec default_max_concurrency() :: pos_integer()
+ def default_max_concurrency, do: @default_max_concurrency
+
+ @spec default_grace_ms() :: non_neg_integer()
+ def default_grace_ms, do: @default_grace_ms
+
+ @spec run([{role(), job}], (job -> result()), keyword()) :: [result()] when job: term()
+ def run(jobs, fun, opts \\ []) when is_function(fun, 1) do
+ state = %{
+ fun: fun,
+ max: Keyword.get(opts, :max_concurrency, @default_max_concurrency),
+ grace_ms: Keyword.get(opts, :grace_ms, @default_grace_ms),
+ pending: Enum.with_index(jobs, fn {role, job}, index -> {index, role, job} end),
+ running: %{},
+ results: %{},
+ deadline: nil
+ }
+
+ state
+ |> fill()
+ |> loop()
+ |> Map.fetch!(:results)
+ |> Enum.sort_by(&elem(&1, 0))
+ |> Enum.map(&elem(&1, 1))
+ end
+
+ defp loop(%{running: running, pending: []} = state) when map_size(running) == 0, do: state
+
+ defp loop(state) do
+ state
+ |> await()
+ |> fill()
+ |> loop()
+ end
+
+ defp await(%{running: running} = state) do
+ receive do
+ {ref, result} when is_map_key(running, ref) ->
+ on_reply(state, ref, result)
+
+ {:DOWN, ref, :process, _pid, reason} when is_map_key(running, ref) ->
+ on_down(state, ref, reason)
+ after
+ wait_ms(state) -> abandon_secondaries(state)
+ end
+ end
+
+ defp on_reply(state, ref, result) do
+ Process.demonitor(ref, [:flush])
+ finish(state, ref, result)
+ end
+
+ defp on_down(state, ref, reason),
+ do: finish(state, ref, %Error{reason: {:source_crashed, reason}})
+
+ defp finish(state, ref, result) do
+ {{index, _role, _task}, running} = Map.pop(state.running, ref)
+
+ %{state | running: running, results: Map.put(state.results, index, result)}
+ |> maybe_start_grace(result)
+ end
+
+ defp maybe_start_grace(%{deadline: nil} = state, %Response{}),
+ do: %{state | deadline: now_ms() + state.grace_ms}
+
+ defp maybe_start_grace(state, _result), do: state
+
+ defp fill(%{pending: [{index, role, job} | rest]} = state)
+ when map_size(state.running) < state.max do
+ if role == :secondary and past_deadline?(state) do
+ fill(%{state | pending: rest, results: Map.put(state.results, index, abandoned())})
+ else
+ %Task{ref: ref} = task = Task.async(fn -> safe_run(state.fun, job) end)
+ fill(%{state | pending: rest, running: Map.put(state.running, ref, {index, role, task})})
+ end
+ end
+
+ defp fill(state), do: state
+
+ # No grace window yet, or nothing left that the window applies to: wait for
+ # the next result.
+ defp wait_ms(%{deadline: nil}), do: :infinity
+
+ defp wait_ms(state) do
+ if secondary_left?(state), do: max(state.deadline - now_ms(), 0), else: :infinity
+ end
+
+ defp secondary_left?(state) do
+ Enum.any?(state.running, &secondary_task?/1) or
+ Enum.any?(state.pending, fn {_index, role, _job} -> role == :secondary end)
+ end
+
+ defp abandon_secondaries(state) do
+ {secondaries, primaries} = Enum.split_with(state.running, &secondary_task?/1)
+ {skipped, pending} = Enum.split_with(state.pending, &match?({_index, :secondary, _job}, &1))
+
+ results =
+ Enum.reduce(secondaries, state.results, fn {_ref, {index, _role, task}}, acc ->
+ Map.put(acc, index, shutdown_result(task))
+ end)
+
+ results = Enum.reduce(skipped, results, &Map.put(&2, elem(&1, 0), abandoned()))
+ %{state | running: Map.new(primaries), pending: pending, results: results}
+ end
+
+ defp secondary_task?({_ref, {_index, role, _task}}), do: role == :secondary
+
+ # A job that finished between the timeout and the kill keeps its answer.
+ defp shutdown_result(task) do
+ case Task.shutdown(task, :brutal_kill) do
+ {:ok, result} -> result
+ _ -> abandoned()
+ end
+ end
+
+ defp past_deadline?(%{deadline: nil}), do: false
+ defp past_deadline?(%{deadline: deadline}), do: now_ms() >= deadline
+
+ defp abandoned, do: %Error{reason: :source_abandoned}
+
+ defp safe_run(fun, job) do
+ fun.(job)
+ rescue
+ exception -> %Error{reason: {:source_crashed, Exception.message(exception)}}
+ catch
+ kind, reason -> %Error{reason: {:source_crashed, {kind, reason}}}
+ end
+
+ defp now_ms, do: System.monotonic_time(:millisecond)
+end
diff --git a/test/bep7_multihomed_announce_test.exs b/test/bep7_multihomed_announce_test.exs
new file mode 100644
index 0000000..05d977c
--- /dev/null
+++ b/test/bep7_multihomed_announce_test.exs
@@ -0,0 +1,669 @@
+defmodule Bep7MultihomedAnnounceTest do
+ @moduledoc """
+ BEP 7 multi-homed announce: a tracker announce leaves from every eligible
+ local address, not only from the primary one per family.
+
+ The address discovery is checked against synthetic `getifaddrs` lists, the
+ concurrency/abandon rules of `Tracker.Fanout` against fake jobs, and the
+ whole path against loopback BEP 15 and HTTP trackers. Those last ones stand
+ in for a multi-homed host with `127.0.0.1`, `127.0.0.2` and `127.0.0.3` —
+ Linux routes all of `127.0.0.0/8` to the loopback interface; a host that
+ cannot bind the aliases (macOS without `ifconfig lo0 alias`) skips them.
+ """
+ use ExUnit.Case, async: false
+
+ import ExUnit.CaptureLog
+
+ alias Tracker.{Error, Fanout, Response, UDP}
+
+ @hash :crypto.strong_rand_bytes(20)
+ @protocol_id UDP.protocol_id()
+ @connection_id <<0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE>>
+ @fast_fail [max_udp_attempts: 0, http_timeout_ms: 5_000]
+ @started [uploaded: 0, downloaded: 0, left: 16_384, event: 2]
+
+ @lo1 {127, 0, 0, 1}
+ @lo2 {127, 0, 0, 2}
+ @lo3 {127, 0, 0, 3}
+
+ @loopback_aliases (case :gen_udp.open(0, [:binary, ip: {127, 0, 0, 2}]) do
+ {:ok, socket} ->
+ :gen_udp.close(socket)
+ true
+
+ {:error, _reason} ->
+ false
+ end)
+ @alias_skip if @loopback_aliases, do: false, else: "127.0.0.2 is not bindable on this host"
+
+ setup do
+ {:ok, _} = Application.ensure_all_started(:elixir_torrent)
+ :ok
+ end
+
+ describe "Acceptor.announce_sources_from/3 — which addresses announce" do
+ test "one interface with several IPv6 addresses announces only the primary" do
+ ifs = [
+ {~c"lo", [flags: [:up, :running, :loopback], addr: @lo1, addr: {0, 0, 0, 0, 0, 0, 0, 1}]},
+ {~c"en0",
+ [
+ flags: [:up, :running, :multicast],
+ addr: {192, 168, 1, 15},
+ addr: {0x2A01, 0x5A8, 0x302, 0xD612, 1, 2, 3, 4},
+ # privacy-extension temporaries on the same NIC
+ addr: {0x2A01, 0x5A8, 0x302, 0xD612, 5, 6, 7, 8},
+ addr: {0x2A01, 0x5A8, 0x302, 0xD612, 9, 9, 9, 9}
+ ]}
+ ]
+
+ snapshot = Acceptor.compute_all_global_ips({:ok, ifs})
+
+ assert snapshot.announce_sources == %{
+ inet: [{192, 168, 1, 15}],
+ inet6: [{0x2A01, 0x5A8, 0x302, 0xD612, 1, 2, 3, 4}]
+ }
+ end
+
+ test "a second NIC and a VPN add one source each, primary first" do
+ gua_wifi = {0x2001, 0xDB8, 0xAAAA, 0, 0, 0, 0, 0x10}
+ gua_vpn = {0x2001, 0xDB8, 0xBBBB, 0, 0, 0, 0, 0x20}
+
+ ifs = [
+ {~c"lo", [flags: [:up, :running, :loopback], addr: @lo1]},
+ {~c"eth0", [flags: [:up, :running, :multicast], addr: {192, 168, 1, 15}]},
+ {~c"wlan0",
+ [
+ flags: [:up, :running, :multicast],
+ addr: {10, 0, 0, 7},
+ addr: {0xFE80, 0, 0, 0, 1, 2, 3, 4},
+ addr: gua_wifi
+ ]},
+ {~c"tun0", [flags: [:up, :running, :pointtopoint], addr: {100, 64, 3, 9}, addr: gua_vpn]},
+ {~c"eth1", [flags: [:broadcast], addr: {172, 16, 0, 9}]}
+ ]
+
+ snapshot = Acceptor.compute_all_global_ips({:ok, ifs})
+
+ assert snapshot.announce_sources.inet == [{192, 168, 1, 15}, {10, 0, 0, 7}, {100, 64, 3, 9}]
+ assert snapshot.announce_sources.inet6 == [gua_wifi, gua_vpn]
+
+ assert snapshot.inet_all == [
+ {192, 168, 1, 15},
+ {10, 0, 0, 7},
+ {100, 64, 3, 9},
+ {172, 16, 0, 9}
+ ]
+ end
+
+ test "an added IPv6 source must be one a public tracker can answer" do
+ primary_ula = {0xFD00, 0, 0, 0, 0, 0, 0, 1}
+
+ ifs = [
+ {~c"eth0", [flags: [:up], addr: primary_ula]},
+ {~c"eth1", [flags: [:up], addr: {0xFD12, 0x3456, 0, 0, 0, 0, 0, 2}]},
+ {~c"eth2", [flags: [:up], addr: {0xFEC0, 0, 0, 0, 0, 0, 0, 3}]},
+ {~c"eth3", [flags: [:up], addr: {0, 0, 0, 0, 0, 0xFFFF, 0xC000, 0x0201}]},
+ {~c"eth4", [flags: [:up], addr: {0x2001, 0xDB8, 0, 0, 0, 0, 0, 4}]}
+ ]
+
+ # The primary stays whatever every other subsystem advertises.
+ assert %{inet: [], inet6: [^primary_ula, {0x2001, 0xDB8, 0, 0, 0, 0, 0, 4}]} =
+ Acceptor.announce_sources_from(ifs, nil, primary_ula)
+ end
+
+ test "no address of a family means no source of it, and getifaddrs failure means none" do
+ ifs = [{~c"eth0", [flags: [:up], addr: {192, 168, 1, 15}]}]
+
+ assert %{inet: [{192, 168, 1, 15}], inet6: []} =
+ Acceptor.announce_sources_from(ifs, {192, 168, 1, 15}, nil)
+
+ assert %{announce_sources: %{inet: [], inet6: []}, inet_all: []} =
+ Acceptor.compute_all_global_ips({:error, :eperm})
+ end
+ end
+
+ describe "Acceptor.announce_sources/0 — snapshot, limits and interface changes" do
+ test "reads the snapshot, follows interface changes and honours the cap" do
+ with_snapshot(snapshot([@lo1, @lo2, @lo3, {10, 0, 0, 4}, {10, 0, 0, 5}]), fn ->
+ # default cap: four per family
+ assert Acceptor.announce_sources().inet == [@lo1, @lo2, @lo3, {10, 0, 0, 4}]
+
+ with_tracker_env([max_sources_per_family: 2], fn ->
+ assert Acceptor.announce_sources().inet == [@lo1, @lo2]
+ end)
+
+ with_tracker_env([multihomed_announce: false], fn ->
+ assert Acceptor.announce_sources().inet == [@lo1]
+ end)
+ end)
+
+ # An interface went away between two refreshes.
+ with_snapshot(snapshot([@lo1]), fn ->
+ assert Acceptor.announce_sources().inet == [@lo1]
+ end)
+ end
+
+ test "a snapshot without the multi-homed fields falls back to the primaries" do
+ legacy = %{
+ inet: @lo1,
+ inet6: nil,
+ inet6_all: [],
+ multicast_interfaces: %{inet: [], inet6: []}
+ }
+
+ with_snapshot(legacy, fn ->
+ assert Acceptor.announce_sources() == %{inet: [@lo1], inet6: []}
+ assert Acceptor.all_ipv4() == [@lo1]
+ end)
+ end
+
+ test "a source the host no longer owns is not announced, without raising" do
+ # TEST-NET-2 is never configured on a host: the bind fails.
+ refute Acceptor.source_reaches?({198, 51, 100, 77}, {198, 51, 100, 1}, 6969)
+ # A loopback destination is reachable only from a loopback source.
+ refute Acceptor.source_reaches?({192, 168, 1, 15}, @lo1, 6969)
+ # Family mismatch.
+ refute Acceptor.source_reaches?(@lo1, {0, 0, 0, 0, 0, 0, 0, 1}, 6969)
+ assert Acceptor.source_reaches?(@lo1, @lo1, 6969)
+ end
+ end
+
+ describe "Tracker.Fanout" do
+ test "returns results in job order and turns a crash into that job's error" do
+ results =
+ Fanout.run(
+ [{:primary, 1}, {:secondary, :boom}, {:secondary, 3}],
+ fn
+ :boom -> raise "source exploded"
+ n -> resp([n])
+ end,
+ max_concurrency: 4
+ )
+
+ assert [
+ %Response{peers: [1]},
+ %Error{reason: {:source_crashed, "source exploded"}},
+ %Response{peers: [3]}
+ ] =
+ results
+ end
+
+ test "never runs more than max_concurrency jobs at once" do
+ parent = self()
+
+ runner =
+ Task.async(fn ->
+ Fanout.run(Enum.map(1..4, &{:primary, &1}), blocking_job(parent), max_concurrency: 2)
+ end)
+
+ assert_receive {:job_started, 1, pid1}
+ assert_receive {:job_started, 2, pid2}
+ # The third job only starts once a slot frees up.
+ assert running_jobs(runner) == 2
+ refute_received {:job_started, 3, _pid}
+
+ send(pid1, :release)
+ assert_receive {:job_started, 3, pid3}
+ send(pid2, :release)
+ assert_receive {:job_started, 4, pid4}
+ send(pid3, :release)
+ send(pid4, :release)
+
+ assert [
+ %Response{peers: [1]},
+ %Response{peers: [2]},
+ %Response{peers: [3]},
+ %Response{peers: [4]}
+ ] =
+ Task.await(runner)
+ end
+
+ test "after the first success a hung secondary is abandoned, a hung primary is not" do
+ parent = self()
+
+ runner =
+ Task.async(fn ->
+ Fanout.run(
+ [{:primary, :fast}, {:primary, :slow}, {:secondary, :hung}, {:secondary, :queued}],
+ fn
+ :fast ->
+ resp([:fast])
+
+ name ->
+ send(parent, {:job_started, name, self()})
+
+ receive do
+ :release -> resp([name])
+ end
+ end,
+ max_concurrency: 3,
+ grace_ms: 0
+ )
+ end)
+
+ assert_receive {:job_started, :slow, slow}
+ # The secondary was killed at the end of the (zero) grace window and the
+ # queued one never started; the slow primary still answers.
+ send(slow, :release)
+
+ assert [
+ %Response{peers: [:fast]},
+ %Response{peers: [:slow]},
+ %Error{reason: :source_abandoned},
+ %Error{reason: :source_abandoned}
+ ] = Task.await(runner)
+
+ refute_received {:job_started, :queued, _pid}
+ end
+
+ test "with no success yet, a failing source does not cut the others short" do
+ parent = self()
+
+ runner =
+ Task.async(fn ->
+ Fanout.run(
+ [{:primary, :fails}, {:secondary, :slow}],
+ fn
+ :fails ->
+ %Error{reason: :timeout}
+
+ :slow ->
+ send(parent, {:job_started, :slow, self()})
+
+ receive do
+ :release -> resp([:slow])
+ end
+ end,
+ grace_ms: 0
+ )
+ end)
+
+ assert_receive {:job_started, :slow, slow}
+ send(slow, :release)
+ assert [%Error{reason: :timeout}, %Response{peers: [:slow]}] = Task.await(runner)
+ end
+ end
+
+ describe "UDP tracker announce from several local addresses (BEP 7 + BEP 15)" do
+ @describetag skip: @alias_skip
+
+ test "each distinct source announces once, with the event, and peers are merged" do
+ {port, server} =
+ start_udp_tracker(
+ peers: %{
+ @lo1 => [{{10, 1, 0, 1}, 6881}, {{10, 1, 0, 2}, 6881}],
+ @lo2 => [{{10, 1, 0, 2}, 6881}, {{10, 1, 0, 3}, 6881}],
+ @lo3 => [{{10, 1, 0, 3}, 6881}, {{10, 1, 0, 4}, 6881}]
+ }
+ )
+
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ {result, log} =
+ with_log(fn ->
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ # duplicates and the primary listed again must not add announces
+ @fast_fail ++ [announce_sources: %{inet: [@lo1, @lo2, @lo3, @lo2, @lo1], inet6: []}]
+ )
+ end)
+
+ assert %Response{peers: peers} = result
+
+ # The debug evidence the local validation reads: one line per source.
+ assert log =~
+ ~r/source_result .* event=started .* role=primary source=auto result=ok peers=2/
+
+ assert log =~ ~r/source_result .* role=secondary source=127\.0\.0\.2 result=ok peers=2/
+ assert log =~ ~r/source_result .* role=secondary source=127\.0\.0\.3 result=ok peers=2/
+
+ assert sorted(peers, &{&1.ip, &1.port}) == [
+ {{10, 1, 0, 1}, 6881},
+ {{10, 1, 0, 2}, 6881},
+ {{10, 1, 0, 3}, 6881},
+ {{10, 1, 0, 4}, 6881}
+ ]
+
+ announces = collect_udp_announces(3)
+ assert sorted(announces, & &1.source) == [@lo1, @lo2, @lo3]
+ assert Enum.all?(announces, &(&1.event == Torrent.started()))
+ refute_received {:udp_announce, _announce}
+
+ # An added source leaves ip_field to the packet source (BEP 15).
+ for %{source: source, ip_field: ip_field} <- announces, source != @lo1 do
+ assert ip_field == <<0::32>>
+ end
+ end
+
+ test "a single address per family announces once, as before" do
+ {port, server} = start_udp_tracker(peers: %{@lo1 => [{{10, 1, 0, 1}, 6881}]})
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ assert %Response{peers: [%Peer{ip: {10, 1, 0, 1}}]} =
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ @fast_fail ++ [announce_sources: %{inet: [@lo1], inet6: []}]
+ )
+
+ assert [%{source: @lo1, event: 2}] = collect_udp_announces(1)
+ refute_received {:udp_announce, _announce}
+ end
+
+ test "a tracker error on one source does not lose the others' peers" do
+ {port, server} =
+ start_udp_tracker(
+ peers: %{@lo1 => [{{10, 1, 0, 1}, 6881}], @lo3 => [{{10, 1, 0, 3}, 6881}]},
+ error_from: [@lo2]
+ )
+
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ assert %Response{peers: peers} =
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ @fast_fail ++ [announce_sources: %{inet: [@lo1, @lo2, @lo3], inet6: []}]
+ )
+
+ assert sorted(peers, & &1.ip) == [{10, 1, 0, 1}, {10, 1, 0, 3}]
+ end
+
+ test "a black-holed source does not hold the answer back past the grace window" do
+ # 127.0.0.3's datagrams are dropped: that source would wait out the
+ # whole BEP 15 receive timeout (15 s) on its own.
+ {port, server} =
+ start_udp_tracker(peers: %{@lo1 => [{{10, 1, 0, 1}, 6881}]}, drop_from: [@lo3])
+
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ started_at = System.monotonic_time(:millisecond)
+
+ assert %Response{peers: [%Peer{ip: {10, 1, 0, 1}}]} =
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ @fast_fail ++
+ [announce_sources: %{inet: [@lo1, @lo3], inet6: []}, source_grace_ms: 0]
+ )
+
+ assert System.monotonic_time(:millisecond) - started_at < 10_000
+ end
+
+ test "a malformed concurrency setting falls back to the default instead of hanging" do
+ {port, server} =
+ start_udp_tracker(
+ peers: %{@lo1 => [{{10, 1, 0, 1}, 6881}], @lo2 => [{{10, 1, 0, 2}, 6881}]}
+ )
+
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ with_tracker_env([max_concurrent_source_announces: 0, source_grace_ms: -1], fn ->
+ assert %Response{peers: peers} =
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ @fast_fail ++ [announce_sources: %{inet: [@lo1, @lo2], inet6: []}]
+ )
+
+ assert sorted(peers, & &1.ip) == [{10, 1, 0, 1}, {10, 1, 0, 2}]
+ end)
+ end
+
+ test "when every source fails, the primary's error is the answer" do
+ {port, server} = start_udp_tracker(peers: %{}, error_from: [@lo1, @lo2])
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ assert %Error{reason: "tracker error for 127.0.0.1"} =
+ Tracker.request!(
+ "udp://127.0.0.1:#{port}/announce",
+ @hash,
+ @started,
+ @fast_fail ++ [announce_sources: %{inet: [@lo1, @lo2], inet6: []}]
+ )
+ end
+ end
+
+ describe "HTTP tracker announce from several local addresses (BEP 7)" do
+ @describetag skip: @alias_skip
+
+ test "each source connects from its own address and carries the event once" do
+ {port, server} = start_http_tracker(self(), 3)
+ on_exit(fn -> Process.exit(server, :kill) end)
+
+ assert %Response{peers: peers} =
+ Tracker.request!(
+ "http://127.0.0.1:#{port}/announce?passkey=secret",
+ @hash,
+ @started,
+ @fast_fail ++ [announce_sources: %{inet: [@lo1, @lo2, @lo3, @lo3], inet6: []}]
+ )
+
+ assert sorted(peers, & &1.ip) == [@lo1, @lo2, @lo3]
+
+ announces =
+ for _ <- 1..3 do
+ assert_receive {:http_announce, source, query}, 5_000
+ {source, query}
+ end
+
+ assert sorted(announces, &elem(&1, 0)) == [@lo1, @lo2, @lo3]
+
+ for {_source, query} <- announces do
+ assert query["event"] == "started"
+ assert query["passkey"] == "secret"
+ refute Map.has_key?(query, "ip")
+ end
+
+ refute_received {:http_announce, _source, _query}
+ end
+ end
+
+ ## Fanout helpers
+
+ defp sorted(list, fun), do: Enum.sort(Enum.map(list, fun))
+
+ defp resp(peers), do: %Response{interval: 1_800, complete: 0, incomplete: 0, peers: peers}
+
+ defp blocking_job(parent) do
+ fn n ->
+ send(parent, {:job_started, n, self()})
+
+ receive do
+ :release -> resp([n])
+ end
+ end
+ end
+
+ # Job processes are linked to the runner (Task.async); the runner is also
+ # linked to this test process.
+ defp running_jobs(%Task{pid: runner}) do
+ {:links, links} = Process.info(runner, :links)
+ Enum.count(links, &(&1 != self()))
+ end
+
+ ## Snapshot / config helpers
+
+ defp snapshot(v4s) do
+ %{
+ inet: List.first(v4s),
+ inet6: nil,
+ inet_all: v4s,
+ inet6_all: [],
+ multicast_interfaces: %{inet: [], inet6: []},
+ announce_sources: %{inet: v4s, inet6: []}
+ }
+ end
+
+ defp with_snapshot(snapshot, fun) do
+ key = Acceptor.ip_cache_key()
+ previous = :persistent_term.get(key, :missing)
+ cache_pid = Process.whereis(Acceptor.IpCache)
+
+ if cache_pid, do: :sys.suspend(cache_pid)
+ :persistent_term.put(key, snapshot)
+
+ try do
+ fun.()
+ after
+ case previous do
+ :missing -> :persistent_term.erase(key)
+ value -> :persistent_term.put(key, value)
+ end
+
+ if cache_pid, do: TestSupport.Sync.safe_resume(cache_pid)
+ end
+ end
+
+ defp with_tracker_env(env, fun) do
+ previous = Application.get_env(:elixir_torrent, :tracker, :missing)
+ Application.put_env(:elixir_torrent, :tracker, env)
+
+ try do
+ fun.()
+ after
+ case previous do
+ :missing -> Application.delete_env(:elixir_torrent, :tracker)
+ value -> Application.put_env(:elixir_torrent, :tracker, value)
+ end
+ end
+ end
+
+ ## Fake BEP 15 tracker on 127.0.0.1: answers per source address
+
+ defp start_udp_tracker(opts) do
+ parent = self()
+
+ pid =
+ spawn_link(fn ->
+ {:ok, socket} = :gen_udp.open(0, [:binary, :inet, active: true, ip: @lo1])
+ {:ok, port} = :inet.port(socket)
+ send(parent, {:udp_tracker_ready, port, self()})
+ udp_tracker_loop(socket, parent, Map.new(opts))
+ end)
+
+ receive do
+ {:udp_tracker_ready, port, ^pid} -> {port, pid}
+ after
+ 5_000 -> flunk("UDP tracker failed to start")
+ end
+ end
+
+ defp udp_tracker_loop(socket, parent, state) do
+ receive do
+ {:udp, ^socket, ip, port, data} ->
+ unless ip in Map.get(state, :drop_from, []) do
+ case udp_tracker_reply(data, ip, parent, state) do
+ nil -> :ok
+ reply -> :gen_udp.send(socket, ip, port, reply)
+ end
+ end
+
+ udp_tracker_loop(socket, parent, state)
+ end
+ end
+
+ defp udp_tracker_reply(
+ <>,
+ _ip,
+ _parent,
+ _state
+ )
+ when protocol_id == @protocol_id,
+ do: <<0::32, tid::binary, @connection_id::binary>>
+
+ defp udp_tracker_reply(
+ <<_cid::binary-size(8), 1::32, tid::binary-size(4), _hash::binary-size(20),
+ _peer_id::binary-size(20), _downloaded::64, _left::64, _uploaded::64, event::32,
+ ip_field::binary-size(4), _rest::binary>>,
+ ip,
+ parent,
+ state
+ ) do
+ send(parent, {:udp_announce, %{source: ip, event: event, ip_field: ip_field}})
+
+ if ip in Map.get(state, :error_from, []) do
+ <<3::32, tid::binary, "tracker error for ", Acceptor.format_ip(ip)::binary>>
+ else
+ peers =
+ state.peers
+ |> Map.get(ip, [])
+ |> Enum.map(fn {{a, b, c, d}, peer_port} -> <> end)
+ |> IO.iodata_to_binary()
+
+ <<1::32, tid::binary, 1200::32, 5::32, 10::32, peers::binary>>
+ end
+ end
+
+ defp udp_tracker_reply(_data, _ip, _parent, _state), do: nil
+
+ defp collect_udp_announces(count) do
+ for _ <- 1..count do
+ assert_receive {:udp_announce, announce}, 5_000
+ announce
+ end
+ end
+
+ ## Fake HTTP tracker on 127.0.0.1: answers with the connection's source as peer
+
+ defp start_http_tracker(parent, connections) do
+ pid =
+ spawn_link(fn ->
+ {:ok, listen} =
+ :gen_tcp.listen(0, [:binary, :inet, active: false, reuseaddr: true, ip: @lo1])
+
+ {:ok, port} = :inet.port(listen)
+ send(parent, {:http_tracker_ready, port, self()})
+
+ for _ <- 1..connections do
+ {:ok, socket} = :gen_tcp.accept(listen, 5_000)
+ serve_http_announce(socket, parent)
+ end
+
+ :gen_tcp.close(listen)
+ end)
+
+ receive do
+ {:http_tracker_ready, port, ^pid} -> {port, pid}
+ after
+ 5_000 -> flunk("HTTP tracker failed to start")
+ end
+ end
+
+ defp serve_http_announce(socket, parent) do
+ {:ok, {{a, b, c, d} = source, _source_port}} = :inet.peername(socket)
+ {:ok, request} = recv_http_headers(socket, "")
+ [request_line | _] = String.split(request, "\r\n")
+ ["GET", target, _version] = String.split(request_line, " ")
+ %URI{query: query} = URI.parse(target)
+ query = URI.decode_query(query)
+ send(parent, {:http_announce, source, query})
+
+ body = Bento.encode!(%{"interval" => 1_200, "peers" => <>})
+
+ :ok =
+ :gen_tcp.send(
+ socket,
+ "HTTP/1.1 200 OK\r\ncontent-length: #{byte_size(body)}\r\nconnection: close\r\n\r\n#{body}"
+ )
+
+ :gen_tcp.close(socket)
+ end
+
+ defp recv_http_headers(socket, acc) do
+ if String.contains?(acc, "\r\n\r\n") do
+ {:ok, acc}
+ else
+ case :gen_tcp.recv(socket, 0, 5_000) do
+ {:ok, chunk} -> recv_http_headers(socket, acc <> chunk)
+ error -> error
+ end
+ end
+ end
+end
diff --git a/test/peer_discovery_connection_ids_test.exs b/test/peer_discovery_connection_ids_test.exs
index 8337d51..1f14d8f 100644
--- a/test/peer_discovery_connection_ids_test.exs
+++ b/test/peer_discovery_connection_ids_test.exs
@@ -18,8 +18,10 @@ defmodule PeerDiscoveryConnectionIdsTest do
{:ok, socket} =
:gen_udp.open(0, [:binary, active: false, reuseaddr: true, ip: {127, 0, 0, 1}])
- {:ok, local_port} = :inet.port(socket)
- key = {{127, 0, 0, 1}, 6881, local_port}
+ # Keyed by the local address too: a multi-homed announce binds one socket
+ # per source address, and two of them may share an ephemeral port.
+ {:ok, {local_ip, local_port}} = :inet.sockname(socket)
+ key = {{127, 0, 0, 1}, 6881, local_ip, local_port}
on_exit(fn -> :gen_udp.close(socket) end)
diff --git a/test/tracker_udp_loopback_test.exs b/test/tracker_udp_loopback_test.exs
index 8ed763c..bdce756 100644
--- a/test/tracker_udp_loopback_test.exs
+++ b/test/tracker_udp_loopback_test.exs
@@ -292,7 +292,8 @@ defmodule TrackerUDPLoopbackTest do
send(observer, {:retry_server, :announce, connection_id, next_announce_count})
if next_announce_count == 1 and cache_state == :expired do
- key = {{127, 0, 0, 1}, tracker_port, client_port}
+ # The announce to a loopback tracker is unbound (see `Acceptor`).
+ key = {{127, 0, 0, 1}, tracker_port, {0, 0, 0, 0}, client_port}
send(PeerDiscovery.ConnectionIds, {:timeout, key})
:sys.get_state(PeerDiscovery.ConnectionIds)
end