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