Skip to content

feat(dht): verify inbound-only query sources before routing-table insert (BEP 5) - #37

Merged
daniboybye merged 1 commit into
masterfrom
feat/dht-verify-before-insert
Oct 27, 2026
Merged

daniboybye merged 1 commit into
masterfrom
feat/dht-verify-before-insert

Conversation

@daniboybye

Copy link
Copy Markdown
Owner

Какво прави

Затваря „Remaining strict gap“ на BEP 5 от PROTOCOL.md. До сега възел, който виждаме за пръв път само като източник на входяща KRPC заявка, влизаше в routing table-а веднага като :good. Сега той е кандидат. Отговаряме на заявката му както досега, после пращаме ping на адреса, от който е дошла заявката. В таблицата влиза само след валиден отговор на този ping, и то по обичайния път за отговори: BEP 42 проверка, learn_remote_contact/3, ping-before-evict.

  • Нов модул DHT.Candidates (чист, всички функции приемат now_ms). Държи кандидатите с лимити:

    • най-много 64 кандидата общо и 2 на source IP;
    • TTL 30 s (обикновено ping timeout-ът от 5 s ги приключва много по-рано, TTL-ът е само предпазна мрежа);
    • най-много 16 нови проверъчни ping-а в секунда.

    Пази и броячи: admitted, verified, unanswered, rejected, unsent, expired, dropped_{pending,full,per_ip,rate,busy}.

  • DHT.handle_query/5: първо се праща отговорът (или грешка 203/204), после observe_query_source/2. Нашият отговор не чака проверката и не зависи от нея. Проверката е само един допълнителен ping в опашката.

  • Pending слотове: проверъчните ping-ове заемат най-много ¼ от @max_pending (64 от 256). Така поток от непознати не може да изтощи слотовете за нашите lookup-и.

  • Наблюдаемост: DHT.routing_stats/0 връща routing_v4, routing_v6, candidates и броячите. Същото се логва на debug всяка минута като [dht] routing_stats ….

  • BEP 32: работи и за v4, и за v6 таблицата. Кандидатът се проверява и вкарва в таблицата на собственото си семейство.

Защо

BEP 5: „A good node is a node has responded to one of our queries within the last 15 minutes. A node is also good if it has ever responded to one of our queries and has sent us a query within the last 15 minutes.“ Възел, който само ни е питал, не отговаря на нито едно от двете. Освен това source адресът на UDP заявка може да е подправен, а възел зад NAT, който ни пише, може изобщо да не е достижим отвън. Такива възли не трябва да заемат място в k-bucket-ите и да се раздават на други възли.

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

  1. Възел, който вече е в таблицата на същия адрес (същото id, IP и порт), се опреснява точно както досега (last_seen, last_query_ms), без проба. Възлите, научени от наш отговор (nodes/nodes6, add_node, bootstrap), не са засегнати.
  2. Познато id, заявено от нов адрес, също е кандидат. Досега такава заявка тихо сменяше адреса на съществуващия запис. Това е същата дупка (неподвластна на проверка смяна на адрес), затова я затварям по същия начин. Ако новият адрес отговори, записът се обновява по пътя за отговори.
  3. Неотговорил или BEP 42-отхвърлен кандидат не наказва никой запис в таблицата. id-то му е само заявено, може да е id-то на реален възел, подправено от чужд адрес. Иначе два такива ping-а биха направили реалния възел :bad и той би бил изхвърлен. Има тест точно за това.
  4. Ping, а не find_node: най-евтиният въпрос, по-малък отговор, а BEP 5 ползва ping и за ping-before-evict.
  5. Отговорът на ping-а може да носи различно id от заявеното. Тогава в таблицата влиза id-то от отговора, защото то е доказано от адреса. Това е съществуващото поведение на пътя за отговори.
  6. Без отделен telemetry event. Проектът няма :telemetry, затова DHT.routing_stats/0 плюс периодичен debug ред.
  7. Защо тези лимити: 64 кандидата × 1 ping на кандидат, максимум 16/s. При подправени заявки срещу трета страна усилването е най-много 1 малък ping на допусната заявка и най-много 16 в секунда общо. Паметта е ограничена до 64 малки записа.

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

  • Нов test/dht_candidates_test.exs (14 теста):
    • DHT.Candidates: допускане, повторна проба на същия адрес (:pending), лимити на размер и на IP, rate прозорец и превъртането му, изтичане по TTL (включително при admit).
    • DHT, IPv4:
      • на жицата първо излиза нашият отговор, после проверъчният ping;
      • кандидат без отговор не влиза в таблицата, unanswered = 1;
      • отговорил кандидат влиза като :good, verified = 1;
      • отговор от друг порт не проверява кандидата;
      • BEP 42-невалиден публичен източник не се проверява;
      • познат възел на същия адрес се опреснява без проба;
      • подправено id от нов адрес не вреди на реалния възел дори след две мълчаливи проби;
      • per-IP и rate лимитите при поток (31 източника → 16 ping-а);
      • лимитът от ¼ на pending слотовете.
    • DHT, IPv6: кандидатът влиза само във v6 таблицата; неотговорил v6 кандидат я оставя празна.
  • Обновени съществуващи тестове, защото поведението на жицата нарочно се смени:
    • DHT.RoutingMaintenanceTest: ping-before-evict сега започва след отговора на кандидата, и това вече се проверява.
    • DHT.BEP42InboundTest: exempt адрес влиза в таблицата след отговор, а не веднага.
    • DHT.AnnouncePeerTest, DHT.CoverageBatchTest, DHTIPv6RoutingTest: пропускат нашия проверъчен ping, когато четат отговора от сокета.
  • Two-strike health и ping-before-evict не са пипани. Техните тестове минават.
  • Локално (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 минават. 8 последователни пускания на DHT тестовете са стабилни.
  • Уговорка: контейнерът няма IPv6. Същите 9 IPv6 теста падат там и на чист master (наборът преди и след е идентичен). Сред тях са четирите в DHTIPv6RoutingTest, на които обнових helper-а. GitHub CI е проверката за тях.

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

  • Реален DHT: колко бързо се пълни таблицата и колко от входящите източници отговарят на ping-а. Тестовете са само върху loopback/синтетични пакети. Очаквам леко забавяне (един RTT) за възлите, които преди влизаха от входящи заявки. Основният приток идва от отговорите на нашите lookup-и и bootstrap и не е засегнат.
  • Дали 16 ping-а/s и 64 кандидата са добре настроени за много натоварен seed. Ако dropped_rate/dropped_full растат бързо при реална работа, лимитите са константи в DHT.Candidates и лесно се вдигат.

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

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

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

git fetch origin feat/dht-verify-before-insert
git checkout feat/dht-verify-before-insert
mix deps.get
mix test test/dht_candidates_test.exs   # 14 теста
mix test                                # целият suite

Нужни са един популярен публичен торент (например Ubuntu/Debian ISO) и един магнет без tr=, така че peers да идват само от DHT. Например същият info hash като magnet:?xt=urn:btih:<hash>.

1. Чист старт (за честно сравнение на скоростта на пълнене)

Routing table-ът се пази в .elixir_torrent/dht_routing_table.bin в текущата директория. За сравнение „от нула“ го премести временно:

mv .elixir_torrent/dht_routing_table.bin /tmp/dht_routing_table.bin.bak 2>/dev/null || true
iex -S mix 2>&1 | tee et-dht.log
Logger.configure(level: :debug)
DHT.routing_stats()
# {:ok, %{routing_v4: 0, routing_v6: 0, candidates: 0, verified: 0, unanswered: 0, ...}}

2. 5–10 минути с реален торент

{:ok, pid} = ElixirTorrent.download("/path/to/ubuntu.torrent", download_dir: "/tmp/et-dht")
# на всяка минута:
DHT.routing_stats()

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

grep "\[dht\] routing_stats" et-dht.log

Очаквано:

  • routing_v4 расте през първите минути и стига до стотици, лимитът е 400 на семейство. С глобален IPv6 расте и routing_v6.
  • verified расте: входящи възли, които са минали проверката. unanswered също расте, защото част от входящите са зад NAT или подправени. Съотношението е информативно, не е критерий.
  • candidates във всеки момент е малко (обикновено под 64) и не расте безкрайно. expired остава около 0 (ping timeout-ът ги приключва първи).
  • dropped_rate/dropped_full/dropped_busy са 0 или ниски при обикновен клиент.

3. Сравнение със master (не по-бавно от преди)

Повтори стъпки 1–2 на master със същия торент, по възможност по същото време. На master няма routing_stats, затова:

s = :sys.get_state(DHT)
{DHT.RoutingTable.node_count(s.routing_tables.v4), DHT.RoutingTable.node_count(s.routing_tables.v6)}

Записвай броя на 1, 3, 5 и 10 минута и при двата клона. Очакването е сходни числа. Разлика от няколко възела в първата минута е нормална (един RTT забавяне за входящите източници). Устойчиво по-ниско пълнене след 5–10 минути е регресия и спира merge-а.

След теста върни стария table: mv /tmp/dht_routing_table.bin.bak .elixir_torrent/dht_routing_table.bin.

4. DHT-only магнет

{:ok, pid} = ElixirTorrent.download_magnet("magnet:?xt=urn:btih:<hash>", download_dir: "/tmp/et-dht-magnet")

Metadata трябва да пристигне и свалянето да тръгне само с DHT peers, за време сходно на master. В лога търси [peer_dial] … dht=N с N > 0.

5. Сваляне и seed (регресия)

  1. Остави торента от стъпка 2 да свали докрай и провери хеша (sha256sum /tmp/et-dht/<файл> срещу публикувания).
  2. Остави го да seed-ва 10–15 минути. ElixirTorrent.stats(pid, [:speed, :downloaded]) трябва да показва upload > 0 при достатъчно голям swarm. DHT.routing_stats() трябва да остава стабилен (без растящ candidates, без [error] в лога).
  3. ElixirTorrent.stop_and_serialize(hash), после отново ElixirTorrent.download/2: resume трябва да е нормален, а routing table-ът да се зареди от диска ([dht] listening … routing_v4=N с N > 0).

Generated by Claude Code

BEP 5 calls a node good only once it has answered one of our queries. A
first-seen node that we have only seen as the source of an inbound query is
no longer inserted on that query: it becomes a DHT.Candidates entry, we ping
the address the query came from, and only a valid response inserts it via the
normal response path (BEP 42 check, learn_remote_contact, ping-before-evict).

- The query is answered first; the verification ping is queued after it and
  the reply never depends on it.
- A node already held at the same address refreshes as before. A known id
  claimed from a new address is a candidate too, and an unanswered or
  BEP 42-rejected candidate ping never marks a table entry failed or bad.
- Bounded: 64 candidates, 2 per source IP, 30 s TTL, 16 new pings per second,
  and candidate pings hold at most a quarter of the in-flight query slots.
- Works for the v4 and v6 tables (BEP 32).
- DHT.routing_stats/0 and a once-a-minute [dht] routing_stats debug line show
  table size per family and candidate counters.

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 89.15663% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/elixir_torrent/dht.ex 80.00% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@daniboybye
daniboybye marked this pull request as ready for review October 27, 2026 18:50
@daniboybye
daniboybye merged commit e50b879 into master Oct 27, 2026
4 checks passed
@daniboybye
daniboybye deleted the feat/dht-verify-before-insert branch October 27, 2026 18:51
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