Skip to content

feat(tracker): BEP 7 multi-homed announce from every eligible local address - #36

Merged
daniboybye merged 2 commits into
masterfrom
feat/bep7-multihomed-announce
Oct 25, 2026
Merged

daniboybye merged 2 commits into
masterfrom
feat/bep7-multihomed-announce

Conversation

@daniboybye

Copy link
Copy Markdown
Owner

Какво прави

BEP 7 — пълен multi-homed announce. HTTP и UDP announce към tracker-ите вече излиза от всеки подходящ локален адрес на семейството, всеки със собствен source-bound сокет, вместо само от един основен IPv4 и един основен IPv6 адрес. Tracker-ът вижда отделен peer запис за всеки адрес на машината и връща peers, достижими през всеки от тях.

  • Откриване (Acceptor.announce_sources/0, Acceptor.announce_sources_from/3): от snapshot-а на IpCache (getifaddrs, опреснява се на 30 s). Първо основният адрес (непроменен), после първият подходящ адрес на всеки друг интерфейс, който е :up. Изключени са loopback, link-local, multicast/reserved, а за добавените IPv6 адреси и ULA (fc00::/7), site-local (fec0::/10) и IPv4-mapped/compatible. Лимит: 4 на семейство.
  • Изпълнение (Tracker.Fanout, нов модул): заявките вървят паралелно, най-много 4 едновременно. Основната заявка (:primary) винаги стига до края си със старите timeout-и и retry-и. Добавена заявка (:secondary), която не е отговорила 15 s след първия успешен отговор, се прекъсва (:source_abandoned). Ако заявка гръмне, грешката остава само за нея.
  • Event-и / BEP 31 / BEP 12: всеки различен source адрес праща event-а на заявката (started/completed/stopped) веднъж. Дублиран адрес или адрес, от който основната заявка вече излиза (включително route-избраният адрес на unbound заявка), се пропуска. PeerDiscovery.Announce пак получава един слят резултат на tracker URL, така че event bookkeeping-ът (StartedAnnounces, Model.update_event), retry/cooldown и tier логиката работят веднъж на tracker, както досега.
  • Peers: сливат се и се дедупликират по {ip, port} (съществуващият merge_http_announces/1). Ако всичко се провали, се връща грешката на основната заявка (тя е реалният отговор на tracker-а).
  • Машина с един адрес на семейство: няма secondary заявки, затова се минава точно по стария последователен път (същите bind-ове, същият unbound retry, същият ред при избор на грешка).

Странични поправки, нужни за коректността:

  • BEP 15 connection_id кешът е по {tracker, local_ip, local_port} вместо {tracker, local_port}. Два сокета, вързани към различни адреси, могат да получат един и същи ephemeral порт, а tracker-ите вързват id-то за адреса на клиента.
  • При UDP добавените source-и пращат ip_field = 0 (tracker-ът взема адреса от пакета). Основният адрес в полето би накарал tracker-а да запише основния адрес и за тази заявка.
  • Проверката за self-connection (Handshakes.global_ip_at_listen_port?/1) знае всички локални IPv4 адреси, не само основния. Tracker-ите вече ще ни връщат и вторичните адреси.

Конфигурация (config :elixir_torrent, :tracker, ...): multihomed_announce (по подразбиране true; false връща старото поведение), max_sources_per_family (4), max_concurrent_source_announces (4), source_grace_ms (15 000). При невалидни стойности се ползват тези по подразбиране.

Защо

Това беше точката „BEP 7 — full multi-homed announce“ (Medium) в PROTOCOL.md § Not yet implemented. Хост с VPN, второ NIC или tethering досега беше видим за tracker-ите само през един адрес на семейство.

Ключови решения (избрани без питане)

  1. Един адрес на интерфейс, а не всички адреси. Linux/macOS с privacy extensions държат по няколко временни IPv6 адреса на една карта. Ако анонсираме всичките, получаваме 3–5 записа за една и съща машина при tracker-а и никаква полза. BEP 7 се занимава с различните мрежи, затова броим интерфейсите.
  2. Основният адрес остава 1:1 какъвто беше, включително route логиката на announce_source_ip/3 (swap към routed адреса при VPN) и unbound retry-я при eaddrnotavail. Добавените адреси нямат unbound retry: unbound означава маршрута на основния адрес, а той вече е анонсиран, така че retry би дублирал event-а.
  3. Добавен адрес се ползва само когато tracker адресът е resolve-нат от нас и има маршрут от този адрес (Acceptor.source_reaches?/3: bound UDP connect, без пакет на жицата). HTTP tracker, чието име не сме resolve-нали сами, остава само с основния адрес.
  4. Grace прозорец само за secondary заявките. Така мъртъв вторичен адрес (например VPN, който не рутира tracker-а) не задържа peers-ите на останалите с BEP 15 ladder-а или с 5-минутния HTTP timeout. Основните заявки не се режат никога.
  5. Event-ът се праща от всеки адрес. Всеки адрес е отделен peer запис за tracker-а. stopped само от основния би оставил другите записи да висят до изтичане при tracker-а. Ако основната заявка се провали, а вторична мине, Announce отбелязва started като доставен. Следващият announce от основния адрес ще е без event, което tracker-ите приемат като implicit start.
  6. Private torrents (BEP 27) не са изключени. Поведението е като при libtorrent (announce от всеки listen socket). Ако някой private tracker се оплаква от „много IP-та“, multihomed_announce: false го изключва глобално.
  7. Debug лог вместо telemetry. Проектът няма зависимост от :telemetry и не исках да я добавям само за това. Всеки announce пише по един ред на source адрес:
    [tracker_announce] source_result hash=… tracker=udp://host:port event=started dest=1.2.3.4 family=inet role=secondary source=10.8.0.2 result=ok peers=37
  8. Клонът е feat/bep7-multihomed-announce, както поиска.

Как е тествано

  • Нов test/bep7_multihomed_announce_test.exs (18 теста, без реални интерфейси и без мрежа):
    • announce_sources_from/3 върху синтетични getifaddrs списъци: един NIC с няколко IPv6 адреса дава само основния; две NIC + VPN дават по един адрес на интерфейс, основният е първи; ULA/site-local/mapped не се добавят; down/loopback интерфейси се прескачат; при getifaddrs грешка списъкът е празен.
    • announce_sources/0 с инжектиран snapshot: лимит, multihomed_announce: false, смяна на snapshot-а (махнат интерфейс), стар snapshot без новите полета. source_reaches?/3 за адрес, който хостът не притежава, за family mismatch и за loopback дестинация.
    • Tracker.Fanout: ред на резултатите, изолиран crash, лимит на конкурентност, прекъсване на висящ secondary и непрекъсване на висящ primary, без прекъсване преди първия успех.
    • Интеграционни срещу loopback BEP 15 и HTTP tracker с източници 127.0.0.1/.2/.3 (инжектирани през announce_sources: в request opts). Проверяват: всеки различен адрес анонсира точно веднъж и с event-а; дубликатите не добавят заявки; peers са слети и дедупликирани; ip_field = 0 за добавените; грешка от един адрес не губи peers-ите на другите; black-holed адрес не задържа отговора; при пълен провал се връща грешката на основния; невалиден config не блокира; private-tracker query (passkey) се пази.
    • Тестовете са event-driven, без sleep. Ако 127.0.0.2 не може да се bind-не (macOS без lo0 alias), интеграционните блокове се skip-ват.
  • Локално (Elixir 1.20.4 / OTP 29.1.2): mix format --check-formatted, compile --warnings-as-errors (dev и test), scripts/compile-property-deps.sh, mix dialyzer, mix credo --all, mix sobelow и timeout 60s mix coveralls.json минават. 15 последователни пускания на tracker тестовете са стабилни.
  • Уговорка: контейнерът, в който работих, няма IPv6. 9 съществуващи IPv6 теста (DHTIPv6RoutingTest, IPv6 тестовете в HTTPTrackerIPv6Test/TrackerUDPLoopbackTest/SourceRouteBindTest/AcceptorDialHandshakeCoverageBatchTest) падат там и на чист master. Наборът провали преди и след промяната е идентичен. GitHub CI е истинската проверка за тях.
  • Обновени са два съществуващи теста, които хардкодват формата на ключа в ConnectionIds.

Какво не е проверено

  • Реален multi-homed хост (две NIC, VPN, IPv4+IPv6 на различни интерфейси) и реални tracker-и. Само loopback.
  • Windows (strong host model). Очакването е, че добавен адрес, чийто интерфейс не рутира tracker-а, получава eaddrnotavail/няма маршрут и се пропуска, без retry.
  • Известна граница: добавеният адрес е bind-нат, но не е закачен за интерфейса си (няма SO_BINDTODEVICE). На Linux/macOS без source-based routing пакетите му може да излязат през default route и да бъдат изхвърлени upstream. Тогава тази заявка изтича или бива прекъсната от grace прозореца, а останалите не страдат. Описано е в PROTOCOL.md.
  • Scrape продължава само от основния адрес.
  • stopped при stop_and_serialize/1 остава best-effort в съществуващия 15 s бюджет на PeerDiscovery.stopped_announce/1. Таймаутът му се хваща, така че няма срив, но stopped от вторичен адрес, който отговаря бавно, може да не стигне до tracker-а. Ще изтече при него.
  • PROTOCOL.md: BEP 7 минава от Partial на Substantially Full с честно изброен остатък. Ако след локалната валидация решиш, че трябва да остане Partial до live проверка, е един ред.

Локална валидация

Не merge-вай, преди да минат стъпки 0–7.

0. Подготовка

git fetch origin feat/bep7-multihomed-announce
git checkout feat/bep7-multihomed-announce
mix deps.get
mix test test/bep7_multihomed_announce_test.exs   # 18 теста; на macOS интеграционните се skip-ват без lo0 alias
# по желание, за да не се skip-ват на macOS:
#   sudo ifconfig lo0 alias 127.0.0.2 up && sudo ifconfig lo0 alias 127.0.0.3 up
mix test                                           # целият suite

Подготви един реален публичен торент с HTTP и UDP tracker-и. Например официалният Ubuntu/Debian ISO .torrent (HTTP tracker с IPv4 и IPv6) или magnet с tr=udp://tracker.opentrackr.org:1337/announce. Нужни са sha256sum/shasum -a 256 и по желание tcpdump.

1. Кои адреси ще анонсират

ip -br addr        # Linux   (macOS: ifconfig; Windows: ipconfig)
iex -S mix 2>&1 | tee et-run.log
Acceptor.all_global_ips()        # :inet / :inet6 основни, :inet_all, :announce_sources
Acceptor.announce_sources()      # реално използваните, след лимита и config-а

Очаквано: основният адрес е първи. Има по един адрес за всеки друг интерфейс, който е up и има подходящ адрес. Няма loopback, fe80::, fd..:: (ULA), няма втори временен IPv6 от същата карта.

2. Debug лог и старт на торент

Logger.configure(level: :debug)
{:ok, pid} = ElixirTorrent.download("/path/to/ubuntu.torrent", download_dir: "/tmp/et-bep7")

В друг терминал:

grep -E "\[tracker_announce\] (source_result|http_endpoints)" et-run.log

Всеки ред source_result показва tracker, event, dest, family, role, source (auto = unbound, т.е. маршрутът избира) и result=ok peers=N или result=error reason=….

3. Машина с един адрес на семейство (регресия)

Изключи VPN и второто NIC, остави един IPv4 (и евентуално един IPv6).

  • Acceptor.announce_sources() дава най-много по 1 адрес на семейство.
  • За всеки tracker адрес има само role=primary, по един ред на семейство (family=inet и, ако tracker-ът има AAAA и машината има IPv6, family=inet6).
  • Няма нито един role=secondary. Първият announce е event=started, следващите са event=none.
  • Поведението и времената трябва да са като на master. Може да сравниш същата сесия с master: там няма редове source_result, но има http_endpoints и udp family=….

4. Няколко адреса (IPv4+IPv6, VPN, второ NIC)

Включи VPN (WireGuard/OpenVPN) или свържи едновременно Ethernet и Wi-Fi. Изчакай до 30 s (IpCache refresh) и пусни отново Acceptor.announce_sources(): новият адрес трябва да е в списъка.

Рестартирай торента, за да има свеж started:

[hash] = ElixirTorrent.list()
ElixirTorrent.stop_and_serialize(hash)
{:ok, pid} = ElixirTorrent.download("/path/to/ubuntu.torrent", download_dir: "/tmp/et-bep7")

Очаквано за всеки tracker адрес:

  • един role=primary плюс по един role=secondary source=<адрес на VPN/второто NIC> на семейство;
  • всеки source се среща точно веднъж с event=started в рунда (никакви дубли от един адрес);
  • source, който tracker-ът не може да достигне, дава result=error или reason=:source_abandoned най-много ~15 s след първия успех, а торентът продължава с peers от другите.

Потвърждение на жицата (по избор):

sudo tcpdump -ni any 'udp port 1337 or tcp port 80 or tcp port 443 or udp port 6969'

Трябва да видиш announce пакети/SYN-ове с различни source адреси към един и същи tracker.

Брой peers: сравни peers= по source редове. Обединеният списък ([peer_dial] … discovered=) трябва да е поне колкото най-големия единичен.

5. Смяна на интерфейсите по време на сесия

Докато торентът тече, изключи VPN (или извади кабела), изчакай 30 s и го включи пак.

  • Процесът не пада: Process.alive?(Process.whereis(Acceptor.IpCache)) дава true, ElixirTorrent.stats(pid) отговаря.
  • В лога може да има source_route_probe_failed / udp_open_failed на debug ниво за изчезналия адрес. Няма crash и няма [error].
  • След връщане на интерфейса адресът отново се появява в Acceptor.announce_sources().

6. Обикновено сваляне и seed (регресия)

  1. Остави торента да свали докрай.
  2. Провери хеша на файла срещу публикувания (sha256sum /tmp/et-bep7/ubuntu-*.iso).
  3. В лога след завършване трябва да има event=completed по веднъж на source към всеки tracker.
  4. Остави го да seed-ва 10–15 минути: ElixirTorrent.stats(pid, [:speed, :downloaded]) трябва да показва upload > 0 (при достатъчно голям swarm).
  5. ElixirTorrent.stop_and_serialize(hash) трябва да даде event=stopped по веднъж на source.
  6. Повтори 1–3 с magnet линк (ElixirTorrent.download_magnet/2) с UDP tracker.

7. Изключване (fallback)

Application.put_env(:elixir_torrent, :tracker, multihomed_announce: false)

След рестарт на торента трябва да има само role=primary редове, т.е. старото поведение и на multi-homed машината. Ако имаш private tracker, провери там, че не отчита проблем с множество IP-та. Ако отчита, това е аргумент за изключване по подразбиране за private torrents (виж решение 6).


Generated by Claude Code

…i-homing)

HTTP and UDP tracker announces now leave from every eligible local address
per family, each on a source-bound socket, instead of one primary IPv4 and
one primary IPv6 address. Eligible: the first usable address of each other
interface that is up (not loopback, link-local, ULA/site-local or
IPv4-mapped), at most 4 per family, read from the IpCache snapshot.

- Single-homed hosts keep the exact old path (sequential, same binds,
  same unbound retry, same error preference).
- Added sources run through Tracker.Fanout: at most 4 concurrent, and a
  secondary still running 15 s after the first success is abandoned;
  primaries always run to their own end.
- Each distinct source carries the event once; duplicates and sources that
  would leave from the primary's address are dropped. Announce still sees
  one merged, peer-deduplicated result per tracker.
- Added UDP sources send ip_field=0; BEP 15 connection_ids are cached per
  local address and port.
- Self-connection checks know every local IPv4 address.
- Config: :tracker multihomed_announce, max_sources_per_family,
  max_concurrent_source_announces, source_grace_ms.
- Debug log line [tracker_announce] source_result per source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7n1Zz63wKTZ8xWKnbaAvT
The source_result debug line now carries event=started|completed|stopped|none,
so a local run can confirm each source sent the event once. The event is
worked out lazily, only when debug logging is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7n1Zz63wKTZ8xWKnbaAvT
@codecov

codecov Bot commented Oct 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.92265% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/elixir_torrent/acceptor.ex 91.83% 4 Missing ⚠️
lib/elixir_torrent/tracker/fanout.ex 91.83% 4 Missing ⚠️
lib/elixir_torrent/tracker.ex 96.20% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@daniboybye
daniboybye marked this pull request as ready for review October 25, 2026 15:53
@daniboybye
daniboybye merged commit 7ad7ff2 into master Oct 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants