diff --git a/Cargo.lock b/Cargo.lock index 92edf5ac9..e35ef5360 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5205,6 +5205,7 @@ dependencies = [ "torrust-info-hash", "torrust-located-error", "torrust-peer-id", + "torrust-tracker-primitives", ] [[package]] @@ -5229,10 +5230,12 @@ dependencies = [ name = "torrust-tracker-primitives" version = "3.0.0" dependencies = [ + "base64", "binascii", "derive_more 2.1.1", "serde", "serde_json", + "sha2 0.11.0", "tdyne-peer-id", "tdyne-peer-id-registry", "thiserror 2.0.20", diff --git a/README.md b/README.md index a3ee7674d..6c376c719 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - [x] Good Performance in Busy Conditions. - [x] Support for `UDP`, `HTTP`, and `TLS` Sockets. - [x] Native `IPv4` and `IPv6` support. +- [x] [I2P] peer announces and matchmaking over HTTP. - [x] Private & Whitelisted mode. - [x] Tracker Management API. - [x] Support [newTrackon][newtrackon] checks. @@ -306,3 +307,4 @@ This project was a joint effort by [Nautilus Cyberneering GmbH][nautilus] and [D [Power2All]: https://github.com/power2all [torrust-demo]: https://github.com/torrust/torrust-demo [prometheus]: https://prometheus.io/ +[I2P]: https://i2p.net/en/docs/applications/bittorrent/ diff --git a/cspell.json b/cspell.json index be5f3d101..42d6e3199 100644 --- a/cspell.json +++ b/cspell.json @@ -18,6 +18,7 @@ ], "ignorePaths": [ ".tmp/**", + "storage/**", "target", "docs/media/*.svg", "contrib/bencode/benches/*.bencode", @@ -33,4 +34,4 @@ "contrib/dev-tools/git/github-merge.py", "docs/issues/**/evidence/*.html" ] -} \ No newline at end of file +} diff --git a/docs/issues/open/1987-add-config-option-to-use-ip-from-announce-query-string/ISSUE.md b/docs/issues/open/1987-add-config-option-to-use-ip-from-announce-query-string/ISSUE.md index 73fa9d2af..3f3c0c3f8 100644 --- a/docs/issues/open/1987-add-config-option-to-use-ip-from-announce-query-string/ISSUE.md +++ b/docs/issues/open/1987-add-config-option-to-use-ip-from-announce-query-string/ISSUE.md @@ -10,7 +10,7 @@ related-pr: null depends-on: - docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md blocks: null -last-updated-utc: 2026-07-15 00:00 +last-updated-utc: 2026-08-17 00:00 semantic-links: skill-links: - create-issue @@ -36,6 +36,19 @@ Add an optional per-HTTP-tracker configuration setting that allows the tracker t The Torrust Tracker HTTP announce handler always derives the peer IP from the TCP connection (or from the `X-Forwarded-For` header when running behind a reverse proxy). The `ip` GET parameter — defined as optional in [BEP 3](https://www.bittorrent.org/beps/bep_0003.html) — is parsed but then **silently ignored**. +#### I2P protocol exception + +PR [#2050](https://github.com/torrust/torrust-tracker/pull/2050) introduces a +protocol-specific exception. A valid I2P Destination supplied in the `ip` +parameter is used as the peer address regardless of this future setting. I2P +BitTorrent requires this reuse of the standard announce parameter because an +I2P peer is addressed by its Destination rather than by an IP address and port. + +This issue's setting applies only to values parsed as clearnet `IpAddr`. It +must not disable I2P Destination processing, and it must not make arbitrary +clearnet IP spoofing the default. This exception and its security rationale +require an ADR when this issue is implemented. + BEP 3 states: > An optional parameter giving the IP (or dns name) which this peer is at. Generally used for the origin if it's on the same machine as the tracker. @@ -68,7 +81,9 @@ Enabling this feature allows a remote client to claim any IP address in its anno - Add a new optional boolean configuration field to the per-HTTP-tracker configuration (name TBD during schema design, e.g. `use_ip_from_query_string`), disabled by default. - When the option is enabled, and the `ip` GET parameter contains a valid IP address, use that IP as the peer's address instead of the connection IP. +- Always use a valid I2P Destination in the `ip` GET parameter as the I2P peer address, independently of `use_ip_from_query_string`. - Document the security implications of enabling this option in the configuration schema and in the module documentation. +- Create an ADR documenting the `ip` parameter precedence: I2P Destination first; otherwise, a clearnet IP only when the opt-in setting is enabled; otherwise, the resolved connection IP. - Add contract tests covering both the enabled and disabled behaviour. ### Out of Scope @@ -82,17 +97,18 @@ Enabling this feature allows a remote client to claim any IP address in its anno Status values: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`. -| ID | Status | Task | Notes / Expected Output | -| --- | ------ | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| T1 | TODO | Design the configuration field name and schema placement | Align with #1978 schema v3.0.0 design; propose name (e.g. `use_ip_from_query_string`) | -| T2 | TODO | Add the field to the per-HTTP-tracker configuration struct | Target the v3.0.0 schema under `packages/configuration/` as part of the #1978 overhaul | -| T3 | TODO | Thread the config value through to the announce service | `packages/http-core/src/services/announce.rs` `peer_from_request` | -| T4 | TODO | Implement the conditional IP selection in `peer_from_request` | Use `announce_request.ip` if `use_ip_from_query_string` is `true` and the field is `Some`; otherwise use the connection IP. When both `use_ip_from_query_string` and `on_reverse_proxy` are enabled, the query string IP takes precedence. Requires prerequisite issue (rename `peer_addr` → `ip`) to be merged first. | -| T5 | TODO | Add contract tests for enabled and disabled behaviour | New tests in `packages/axum-http-server/tests/` | -| T6 | TODO | Update configuration documentation | `packages/configuration/` docs and `share/default/` config file | -| T7 | TODO | Run `cargo test --workspace` — no regressions | All tests pass | -| T8 | TODO | Run `linter all` | Must exit `0` | -| T9 | TODO | Update migration guide if this subissue affects the config public API | `docs/issues/open/1978-configuration-overhaul-epic/configuration-v2-to-v3-migration.md` | +| ID | Status | Task | Notes / Expected Output | +| --- | ------ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| T1 | TODO | Design the configuration field name and schema placement | Align with #1978 schema v3.0.0 design; propose name (e.g. `use_ip_from_query_string`) | +| T2 | TODO | Add the field to the per-HTTP-tracker configuration struct | Target the v3.0.0 schema under `packages/configuration/` as part of the #1978 overhaul | +| T3 | TODO | Thread the config value through to the announce service | `packages/http-core/src/services/announce.rs` `peer_from_request` | +| T4 | TODO | Implement conditional address selection in `peer_from_request` | Always use `AnnounceAddress::I2p` for I2P protocol compatibility. For `AnnounceAddress::Ip`, use the query value only when `use_ip_from_query_string` is `true`; otherwise use the resolved connection IP. When both `use_ip_from_query_string` and `on_reverse_proxy` are enabled, an opted-in query-string IP takes precedence. Requires prerequisite issue (rename `peer_addr` → `ip`) to be merged first. | +| T5 | TODO | Add contract tests for enabled and disabled behaviour | New tests in `packages/axum-http-server/tests/` | +| T6 | TODO | Update configuration documentation | `packages/configuration/` docs and `share/default/` config file | +| T7 | TODO | Run `cargo test --workspace` — no regressions | All tests pass | +| T8 | TODO | Run `linter all` | Must exit `0` | +| T9 | TODO | Update migration guide if this subissue affects the config public API | `docs/issues/open/1978-configuration-overhaul-epic/configuration-v2-to-v3-migration.md` | +| T10 | TODO | Create ADR for I2P `ip` parameter precedence | Record the protocol exception, security rationale, and precedence order before implementation is merged. | ## Progress Tracking @@ -113,13 +129,15 @@ Status values: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`. ### Progress Log +- 2026-08-17 00:00 UTC - Jose Celano/Copilot - Documented the I2P protocol exception: valid Destinations in the `ip` parameter bypass the future clearnet-IP opt-in setting. Added the required address precedence, acceptance criterion, verification scenario, and ADR task. - 2026-07-15 00:00 UTC - Copilot/User - Spec drafted as a sub-issue of #1978; feature deferred to the configuration overhaul epic. ## Acceptance Criteria -- [ ] AC1: When `use_ip_from_query_string` is `false` (default), the tracker always uses the connection IP regardless of the `ip` GET parameter. +- [ ] AC1: When `use_ip_from_query_string` is `false` (default), the tracker uses the connection IP for an `ip` GET parameter containing a clearnet IP address. - [ ] AC2: When `use_ip_from_query_string` is `true` and a valid IP is provided in the `ip` GET parameter, the tracker uses that IP as the peer's address. - [ ] AC3: When `use_ip_from_query_string` is `true` but the `ip` GET parameter is absent or contains a non-IP value, the tracker falls back to the connection IP. +- [ ] AC3a: A valid I2P Destination in the `ip` GET parameter is used as an I2P peer address whether `use_ip_from_query_string` is enabled or disabled. - [ ] AC4: The default configuration file (`share/default/`) has `use_ip_from_query_string` set to `false` (or omitted, defaulting to `false`). - [ ] AC5: The configuration schema documentation clearly states the security implications of enabling this option. - [ ] AC6: Contract tests cover both enabled and disabled cases. @@ -141,12 +159,13 @@ Status values: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`. Status values: `TODO`, `IN_PROGRESS`, `DONE`, `FAILED`, `BLOCKED`. -| ID | Scenario | Command/Steps | Expected Result | Status | Evidence | -| --- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------- | ------ | -------- | -| M1 | Default config: `ip` GET param is ignored | Start tracker with default config; announce with `ip=1.2.3.4` from a different source IP; check the peer list | Peer is registered with the connection IP, not `1.2.3.4` | TODO | | -| M2 | Opt-in config: `ip` GET param is used | Enable `use_ip_from_query_string`; announce with `ip=1.2.3.4`; check the peer list | Peer is registered with `1.2.3.4` | TODO | | -| M3 | Opt-in config: no `ip` param — fallback | Enable `use_ip_from_query_string`; announce without `ip` param | Peer is registered with the connection IP | TODO | | -| M4 | Opt-in + reverse proxy: `ip` param takes precedence | Enable both `use_ip_from_query_string` and `on_reverse_proxy`; announce with `ip=1.2.3.4` and `X-Forwarded-For: 5.6.7.8` | Peer is registered with `1.2.3.4` (query string wins) | TODO | | +| ID | Scenario | Command/Steps | Expected Result | Status | Evidence | +| --- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------ | -------- | +| M1 | Default config: `ip` GET param is ignored | Start tracker with default config; announce with `ip=1.2.3.4` from a different source IP; check the peer list | Peer is registered with the connection IP, not `1.2.3.4` | TODO | | +| M2 | Opt-in config: `ip` GET param is used | Enable `use_ip_from_query_string`; announce with `ip=1.2.3.4`; check the peer list | Peer is registered with `1.2.3.4` | TODO | | +| M3 | Opt-in config: no `ip` param — fallback | Enable `use_ip_from_query_string`; announce without `ip` param | Peer is registered with the connection IP | TODO | | +| M4 | Opt-in + reverse proxy: `ip` param takes precedence | Enable both `use_ip_from_query_string` and `on_reverse_proxy`; announce with `ip=1.2.3.4` and `X-Forwarded-For: 5.6.7.8` | Peer is registered with `1.2.3.4` (query string wins) | TODO | | +| M5 | Default config: valid I2P Destination is used | Start tracker with default config; announce a valid I2P Destination in `ip`; inspect the peer list or response | Peer is registered as an I2P peer despite the clearnet IP option being disabled | TODO | | ### Acceptance Verification @@ -166,6 +185,7 @@ Status values: `TODO`, `IN_PROGRESS`, `DONE`, `FAILED`, `BLOCKED`. - **IP spoofing**: When enabled, a client can register any IP address in the peer list. This is inherent to the feature and must be clearly documented. The opt-in default mitigates the risk for deployments that do not need this. - **Interaction with reverse proxy mode**: Resolved — when both `use_ip_from_query_string` and `on_reverse_proxy` are enabled, the query string `ip` takes precedence. See "Interaction with `on_reverse_proxy`" above for rationale. - **IPv4/IPv6**: The `ip` parameter accepts both IPv4 and IPv6 addresses (via `IpAddr::from_str`). If the tracker is bound to an IPv6-only socket and a client sends an IPv4 `ip`, the address is accepted as-is — the tracker does not validate address family compatibility with the listener binding. +- **Protocol-specific semantics**: I2P reuses `ip` for a Destination, not a clearnet address. The configuration setting therefore cannot uniformly govern all `ip` values; this intentional exception must remain documented by an ADR. ## References diff --git a/docs/packages.md b/docs/packages.md index 69eb24ef9..0b54b574e 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -277,6 +277,13 @@ Packages that have been extracted to their own standalone repositories. - Response bencoding - Error code mapping - Compact peer formatting + - I2P Destination parsing and compact Destination-hash formatting + +HTTP swarms keep I2P peers isolated from clearnet peers. An I2P announce may provide its full +Base64 Destination, with or without the `.i2p` suffix, in the `ip` query parameter. Non-compact +responses return that Destination, while compact responses return its 32-byte SHA-256 hash. +See the [I2P BitTorrent specification](https://i2p.net/en/docs/applications/bittorrent/) for the +wire-format details. ### UDP Tracker (BEP 15) diff --git a/docs/pr-reviews-manual/README.md b/docs/pr-reviews-manual/README.md new file mode 100644 index 000000000..032f09582 --- /dev/null +++ b/docs/pr-reviews-manual/README.md @@ -0,0 +1,28 @@ +# Manual PR Reviews + +This directory contains manual (human-assisted) reviews of pull requests on the +[torrust/torrust-tracker](https://github.com/torrust/torrust-tracker) repository. + +Each review lives in its own subfolder named `pr-/` and may contain +multiple Markdown files — one per review pass or analysis task (e.g. code review, +protocol compliance audit, spec comparison). + +## Structure + +```text +docs/pr-reviews-manual/ +├── README.md ← this file +└── pr-2050/ + ├── review-pass-1.md ← first-pass findings, actions, and questions + ├── protocol-compliance.md ← optional: I2P spec compliance audit + └── ... +``` + +## Differences from `docs/pr-reviews/` + +| Aspect | `docs/pr-reviews/` | `docs/pr-reviews-manual/` | +| ------------ | ----------------------------- | --------------------------------------------------- | +| Scope | Copilot suggestion processing | Manual code review, design analysis, spec audits | +| Workflow | Automated suggestion threads | Human-driven analysis with AI assistance | +| Output | Suggestion tracker files | Multi-file review packages per PR | +| Final report | N/A | Single consolidated report with actions + questions | diff --git a/docs/pr-reviews-manual/pr-2050/destination-spoofing-analysis.md b/docs/pr-reviews-manual/pr-2050/destination-spoofing-analysis.md new file mode 100644 index 000000000..985b53c18 --- /dev/null +++ b/docs/pr-reviews-manual/pr-2050/destination-spoofing-analysis.md @@ -0,0 +1,338 @@ +--- +semantic-links: + pr: "https://github.com/torrust/torrust-tracker/pull/2050" + superseding-pr: "https://github.com/torrust/torrust-tracker/pull/2059" + i2p-bittorrent-spec: "https://i2p.net/en/docs/applications/bittorrent/" + i2p-samv3: "https://i2p.net/en/docs/api/samv3" + related-artifacts: + - packages/http-core/src/services/announce.rs + - packages/http-protocol/src/v1/requests/announce.rs + - packages/primitives/src/i2p.rs + - docs/pr-reviews-manual/pr-2050/i2p-addressing-primer.md +--- + +# I2P Destination Spoofing Analysis — PR #2050 + +This document records the destination-identity threat model for the I2P peer +support proposed in PR #2050, evaluates deployment options, and defines the +minimum requirements before the feature can be merged. + +> **Review status (2026-08-18):** PR #2050 is the historical source under +> review. The signed review baseline is `2050-i2p-peer-support-reviewed`, and +> the active implementation proposal is draft +> [PR #2059](https://github.com/torrust/torrust-tracker/pull/2059). Section 6 +> is the canonical security acceptance checklist for that draft. + +It complements the [I2P addressing primer](i2p-addressing-primer.md) and the +main [review report](review-pass-1.md). + +--- + +## 1. Problem statement + +The current implementation accepts an I2P Destination in the HTTP announce +request's `ip` query parameter and uses it as the peer identity: + +```text +GET /announce?...&ip=.i2p +``` + +A normal HTTP request does not cryptographically prove that the requester owns +that Destination. Any client that can reach the listener can claim another +peer's valid Destination. + +```text +Attacker ── HTTP announce ──► Torrust Tracker + ip=.i2p +``` + +This is **Destination spoofing**. It is distinct from forging an Internet +source IP, but it has the same essential property: an untrusted client controls +the identity recorded by the tracker. + +--- + +## 2. Concrete attack scenario + +Assume an I2P swarm contains Alice, Bob, and Carol. + +1. Alice announces her valid Destination as a seeder. +2. Mallory sends an announce with Alice's Destination in the `ip` parameter. +3. The tracker accepts Mallory's supplied value as `PeerAddress::I2p(Alice)`. +4. The tracker can return I2P peer information to Mallory because Mallory now + appears to belong to the I2P swarm. +5. Mallory can update Alice's tracker record, submit a `stopped` event, or + report manipulated uploaded/downloaded/left counters. + +Potential consequences: + +- disclosure of I2P peer Destinations or compact Destination hashes to an + unauthorized requester; +- peer-record takeover because the swarm is keyed by `PeerAddress`; +- manipulation of peer availability, announces, and swarm statistics; +- unexpected connection attempts toward an impersonated peer; +- bypass of intended I2P/clearnet separation through a forged identity. + +Authentication can restrict _who_ may announce, but it does not prove that an +authenticated user owns the Destination they supply. + +### 2.1 Fabricated-Destination spam and swarm pollution + +Destination spoofing also enables a high-volume abuse case that does not +require an attacker to know a victim's Destination. An attacker can submit +many announces containing distinct, structurally valid, but attacker-unowned +Destinations. The current query-parameter model validates Destination syntax; +it does not prove that an announcer controls the corresponding private keys. + +```text +Attacker ── many HTTP announces ──► Torrust Tracker + ip=.i2p + ip=.i2p + ip=.i2p +``` + +Each accepted Destination can create a distinct `PeerAddress::I2p` record. +At scale, this is more than a repeated instance of a victim-impersonation +attack: it is a resource-exhaustion and swarm-integrity risk. + +Potential consequences: + +- memory, persistence, cleanup, metrics, and registry-index pressure from + large numbers of short-lived peer records; +- CPU consumption for request parsing, Base64 decoding, Destination + validation/hashing, and swarm updates; +- polluted peer lists that cause genuine clients to perform useless I2P lookup + or connection work for nonexistent peers; +- inflated availability and administrative statistics; and +- aggregate bandwidth/CPU pressure from repeatedly generating peer responses, + even when each individual response is bounded. + +Trusted transport identity prevents an attacker from claiming arbitrary random +Destinations: an I2P-enforced listener derives identity from the authenticated +transport context. It does **not** prevent all high-volume abuse, because an +attacker may still repeatedly announce from a genuine Destination or operate +multiple genuine I2P identities. Those cases are conventional announce-rate +limiting and Sybil-resistance concerns, separate from the claimed-identity +trust boundary. + +The secure deployment must therefore combine identity enforcement with +operational abuse controls: bounded Destination input before decoding, per- +Destination and trusted-source announce rate limits, limits on active I2P +peers per swarm and globally, bounded `numwant`/response sizes, efficient peer +expiry, and privacy-aware abuse metrics. These controls must not use an +untrusted query Destination as their only identity key. + +--- + +## 3. Why an I2P-only listener is insufficient + +Restricting a tracker listener to traffic arriving through I2P improves privacy +but does not, by itself, bind the `ip` parameter to the sender. + +```text +Some I2P client ── I2P connection ──► I2P-only tracker listener + ip=.i2p +``` + +The tracker knows that an I2P client made the request, but not that it owns the +Destination stated in the query. A spoofing-safe deployment needs a +**transport-derived identity**, not merely an I2P-only transport path. + +| Deployment | I2P traffic only | Sender Destination authenticated | Spoofing prevented | +| ----------------------------------------------- | ---------------- | -------------------------------- | ----------------------------- | +| Public HTTP + query `ip` | No | No | No | +| I2P-only HTTP + query `ip` | Yes | No | No | +| I2P server tunnel + trusted Destination headers | Yes | Yes | Yes, if configured correctly | +| SAMv3/I2CP transport adapter | Yes | Yes | Yes, if implemented correctly | + +--- + +## 4. Recommended enforcement architecture + +Torrust should own the authorization decision. An I2P component supplies a +trusted transport identity; the tracker validates the source and applies its +announce policy. + +```text +I2P client + │ + ▼ +I2P network + │ + ▼ +Trusted I2P server tunnel / I2P-aware proxy + │ injects or overwrites X-I2P-Dest* headers + ▼ +Loopback-only Torrust I2P listener + │ validates trusted source and identity context + ▼ +Torrust announce application service +``` + +A tunnel implementation can provide headers such as: + +```text +X-I2P-DestB64: +X-I2P-DestHash: +X-I2P-DestB32: .b32.i2p +``` + +These headers are meaningful only when the request originates from a configured +trusted I2P tunnel/proxy. A public listener must never trust client-provided +versions of these headers. + +### Identity rule + +When enforcement is required: + +```text +peer identity = validated Destination from trusted I2P transport context +``` + +The tracker must either ignore the announce `ip` Destination or require it to +exactly match the trusted Destination. + +--- + +## 5. Deployment modes and trade-offs + +### Option A: Do not accept I2P announces until enforcement exists + +```text +Public HTTP listener only +I2P Destination in query `ip` -> rejected +``` + +| Benefits | Costs | +| -------------------------------------- | ----------------------------------- | +| No spoofing path | Delays I2P peer support | +| Simple and explicit security model | Does not provide compatibility mode | +| No tunnel/proxy deployment requirement | | + +This is the safest default for an open/public tracker. + +### Option B: Compatibility mode with unverified Destinations + +```text +Public HTTP listener +I2P Destination in query `ip` -> accepted as unverified +``` + +| Benefits | Costs | +| ------------------------------------------ | ---------------------------------------------------- | +| Enables basic interoperability immediately | Destination spoofing remains possible | +| Works with existing HTTP clients | Can disclose or manipulate I2P swarm state | +| No I2P tunnel configuration | Must never be presented as authenticated I2P support | + +This option is not suitable for an open/public tracker unless access is +strictly controlled and the risk is explicitly accepted. + +### Option C: I2P server tunnel with header enforcement + +```text +I2P server tunnel -> loopback-only Torrust listener +trusted X-I2P-Dest* header -> enforced identity +``` + +| Benefits | Costs | +| --------------------------------------------- | ------------------------------------------ | +| Strong practical identity binding | Requires an I2P router/tunnel deployment | +| Tracker can be reachable as a `.i2p` service | Requires trusted-source configuration | +| Does not require Torrust to implement routing | Direct listener exposure must be prevented | + +This is the recommended first secure deployment mode. + +### Option D: SAMv3 or I2CP transport adapter + +```text +Torrust adapter <-> local I2P router via SAMv3/I2CP +``` + +| Benefits | Costs | +| ----------------------------------------------- | --------------------------------------------------- | +| Direct transport-derived identity context | New transport adapter and operational complexity | +| Strong integration with tracker request context | Requires I2P router dependency and lifecycle design | +| Can avoid proxy-header trust model | Larger future implementation | + +This is a strong long-term design, but it is separate from the peer-address +model proposed by PR #2050. + +--- + +## 6. Minimum requirements before merge + +The initial I2P peer-support feature must not expose I2P swarm membership based +solely on an untrusted claimed Destination. Before merging, choose and implement +one of the following safe policies: + +### Required policy choice + +- [ ] **Policy 1 — Enforced I2P transport identity**: Support I2P announces + only on a dedicated trusted listener. Require trusted I2P transport identity + headers or an equivalent SAMv3/I2CP identity context. +- [ ] **Policy 2 — Disable I2P announces**: Reject I2P Destinations until + transport-derived identity enforcement is available. + +Do not merge a public compatibility mode that treats a query parameter as an +authenticated I2P identity. + +### Required implementation points for Policy 1 + +- [ ] Add per-listener configuration identifying I2P-enforced mode and trusted + tunnel/proxy sources. +- [ ] Reject direct requests and untrusted `X-I2P-Dest*` headers on an + I2P-enforced listener. +- [ ] Parse and validate the trusted full Destination header. +- [ ] Derive `PeerAddress::I2p` from the trusted transport context, not from an + untrusted query parameter. +- [ ] Reject an `ip` Destination that differs from the trusted identity, or + ignore the query value entirely. +- [ ] Keep public clearnet listeners on a distinct policy and reject I2P + identity headers there. +- [ ] Add contract tests for trusted source, untrusted source, missing headers, + malformed headers, matching/mismatching `ip` values, and direct-listener + access. +- [ ] Document an operational deployment where the I2P forwarding listener is + loopback-only and cannot be reached directly from the public Internet. + +### Operational abuse controls + +Identity enforcement removes arbitrary claimed identities, but it is not a +replacement for ordinary denial-of-service and Sybil controls. The design and +deployment must define bounded Destination input before decoding, rate limits +keyed by trusted Destination and trusted tunnel/proxy source, peer-count and +response-size limits, expiry/cleanup behavior, and privacy-aware metrics. Test +the configured limits with repeated announces from one trusted identity and +with multiple trusted identities; do not use the unauthenticated query `ip` +value as the sole rate-limit key. + +--- + +## 7. Future architecture decision + +The destination-enforcement design affects tracker listener configuration, +reverse-proxy trust, authentication, request context, observability, and +network isolation. It requires an ADR before implementation. + +The ADR should decide: + +1. Whether the first secure integration is a trusted I2P server tunnel, a + SAMv3 adapter, or both. +2. The per-listener mode and trusted-source configuration model. +3. Header handling, normalization, precedence, and mismatch policy. +4. How authentication and Destination ownership interact. +5. Logging/redaction requirements for Destinations. +6. Listener/network separation and migration behavior. +7. Test/deployment requirements for secure operation. +8. Rate-limiting, peer-admission, peer-expiry, and observability controls for + fabricated-Destination spam and genuine-identity Sybil abuse. + +--- + +## 8. Recommendation for draft PR #2059 + +Keep PR #2059 as a draft until destination spoofing is addressed by one of the +safe policy choices in Section 6. **No policy is selected or implemented in +the current draft.** This is not merely a future enhancement: it is the trust +boundary that determines whether the tracker records verified I2P peer +identities or attacker-controlled claims. diff --git a/docs/pr-reviews-manual/pr-2050/i2p-addressing-primer.md b/docs/pr-reviews-manual/pr-2050/i2p-addressing-primer.md new file mode 100644 index 000000000..d69b51f32 --- /dev/null +++ b/docs/pr-reviews-manual/pr-2050/i2p-addressing-primer.md @@ -0,0 +1,205 @@ +--- +semantic-links: + pr: "https://github.com/torrust/torrust-tracker/pull/2050" + superseding-pr: "https://github.com/torrust/torrust-tracker/pull/2059" + i2p-bittorrent-spec: "https://i2p.net/en/docs/applications/bittorrent/" + i2p-common-structures-spec: "https://i2p.net/en/docs/specs/common-structures" + related-artifacts: + - packages/primitives/src/i2p.rs + - packages/http-protocol/src/v1/requests/announce.rs + - packages/http-protocol/src/v1/responses/announce/encoding.rs +--- + +# I2P Addressing Primer for PR #2050 + +This note explains the I2P address forms relevant to the Torrust Tracker I2P +peer-support review. It is a review aid, not a replacement for the official +[I2P BitTorrent specification](https://i2p.net/en/docs/applications/bittorrent/) +or the [I2P common-structures specification](https://i2p.net/en/docs/specs/common-structures). + +> **Review status (2026-08-18):** PR #2050 is the historical source for this +> review. The signed review baseline is `2050-i2p-peer-support-reviewed`, and +> the active proposal is draft [PR #2059](https://github.com/torrust/torrust-tracker/pull/2059). +> This primer describes the required merge behavior for that draft; it does not +> claim that all requirements are implemented. + +--- + +## 1. Why I2P does not use an IP address and port + +A clearnet BitTorrent peer is normally identified by a socket endpoint: + +```text +203.0.113.42:6881 +``` + +An I2P peer is identified by a **Destination**. A Destination is a public, +self-contained I2P endpoint identity. I2P routing delivers messages to that +Destination; a TCP-style peer port is not part of I2P peer addressing. + +For BitTorrent tracker compatibility, I2P clients commonly send a fake +`port=6881` in an announce request. Trackers may ignore it, and I2P clients +must ignore the `port` field returned in a non-compact peer dictionary. + +This is why the PR introduces a domain-level distinction: + +```text +PeerAddress +├── Clearnet(SocketAddr) -> IP address + port +└── I2p(I2pPeerAddress) -> Destination only +``` + +--- + +## 2. Destination binary structure + +An I2P Destination is a `KeysAndCert` structure: + +```text ++------------------------------------+-------------------------+ +| 384 bytes: key material / padding | Certificate: 3+ bytes | ++------------------------------------+-------------------------+ +``` + +It is at least **387 bytes** long. The Certificate starts at byte 384 and is: + +```text ++-----------+---------------------+---------------------------+ +| type: 1 B | payload length: 2 B | payload: declared length | ++-----------+---------------------+---------------------------+ +``` + +The full Destination length must therefore be: + +$$ +384 + 1 + 2 + \text{certificate payload length} +$$ + +The PR verifies this total-length relation. A full implementation must also +validate the certificate type and its permitted payload structure/size; the +I2P specification warns implementers not to accept excess Certificate data. + +--- + +## 3. Full Base64 Destination + +Trackers receive the full Destination in the announce request's `ip` +parameter. It uses the **I2P Base64 alphabet**: + +```text +ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~ +``` + +This differs from standard Base64 in the final two symbols (`-~` instead of +`+/`). A minimum 387-byte Destination becomes at least approximately 516 +Base64 characters. I2P BitTorrent clients **must append `.i2p`** for older +tracker compatibility; trackers should also accept the suffixless form. + +Example shape only (not a usable real-world Destination): + +```text + +AAAA...AAAA BQAEAAAAAA==.i2p +└──── Base64 Destination ────┘ +``` + +The `==` is ordinary Base64 padding. In an HTTP query value it may be +percent-encoded as `%3D%3D`. A tracker must percent-decode query values before +interpreting the I2P Base64 Destination. + +--- + +## 4. Destination hash and `.b32.i2p` address + +The compact tracker response does not return each full Destination. It returns +its 32-byte SHA-256 hash: + +```text +destination hash = SHA-256(binary Destination) +``` + +For example, pseudocode equivalent to the PR's calculation is: + +```rust +let binary_destination = decode_i2p_base64(full_destination)?; +let destination_hash: [u8; 32] = Sha256::digest(binary_destination).into(); +``` + +A human-usable I2P Base32 address is derived from the same hash: + +```text +lowercase-base32(SHA-256(binary Destination)).b32.i2p +``` + +The Base32 address is a lookup name, not the full Destination. A client that +only has a compact-response hash must convert it to the `.b32.i2p` form and +query I2P's naming service before connecting. A client that receives a full +Destination in a non-compact response should use it directly. + +--- + +## 5. Tracker announce and response formats + +### Announce request + +```text +GET /announce?...&ip=.i2p&port=6881&compact=1 +``` + +The tracker should: + +1. Percent-decode the HTTP query value exactly once. +2. Parse it as a clearnet IP first when applicable. +3. Parse a valid I2P Destination when it is an I2P candidate. +4. Reject malformed I2P candidates rather than silently registering them as + clearnet peers. + +### Non-compact response + +I2P peers are returned as ordinary peer dictionaries, but `ip` contains the +full Base64 Destination and `port` is only a compatibility placeholder: + +```text +{ + "peer id": <20 bytes>, + "ip": ".i2p", + "port": 1 +} +``` + +### Compact response + +The `peers` byte string contains concatenated 32-byte Destination hashes: + +```text ++--------------------------------+--------------------------------+ +| SHA-256(Destination peer 1) | SHA-256(Destination peer 2) | +| 32 bytes | 32 bytes | ++--------------------------------+--------------------------------+ +``` + +I2P compact entries are not IPv4's 6-byte `IP + port` entries and must not be +parsed by an IPv4 compact-peer decoder. + +--- + +## 6. Why Torrust Tracker should support this + +Supporting I2P lets the tracker serve BitTorrent swarms whose participants use +the I2P anonymity network. This requires protocol-aware support rather than +just accepting a long string in the `ip` field: + +- **Correct matching**: I2P peers need full Destinations or compact hashes of + other I2P peers. +- **Network separation**: clearnet peers must not receive I2P peers, and I2P + peers must not receive clearnet peers. +- **Accurate statistics**: announce peer lists and swarm counts must describe + peers reachable by the requester. +- **Safety**: Destination input is public/untrusted HTTP input, so it must be + bounded, structurally validated, and not reflected in full in error messages. +- **Future API clarity**: management APIs should not ambiguously serialize a + Destination as if it were a socket address. + +PR #2050 establishes the core domain model and HTTP tracker path. The manual +review documents the remaining protocol, input-safety, response-decoding, and +API-contract work needed before it is merge-ready. diff --git a/docs/pr-reviews-manual/pr-2050/manual-test-evidence.md b/docs/pr-reviews-manual/pr-2050/manual-test-evidence.md new file mode 100644 index 000000000..d20f9c770 --- /dev/null +++ b/docs/pr-reviews-manual/pr-2050/manual-test-evidence.md @@ -0,0 +1,375 @@ +--- +semantic-links: + pr: "https://github.com/torrust/torrust-tracker/pull/2050" + superseding-pr: "https://github.com/torrust/torrust-tracker/pull/2059" + pr-title: "Feat/i2p peer support" + review-pass: "review-pass-1.md" +--- + +# Manual Test Evidence — PR #2050 I2P Peer Support + +Empirical verification of I2P peer behavior in the Torrust Tracker. +This file documents every command, output, and observation needed to +repeat the experiments. + +> **Review status (2026-08-18):** These results were captured from the +> historical PR #2050 source at `1bb9e9a3`. Findings now apply to the signed +> review baseline `2050-i2p-peer-support-reviewed` and draft +> [PR #2059](https://github.com/torrust/torrust-tracker/pull/2059). Re-run the +> checks after implementation changes before treating them as PR #2059 results. + +--- + +## Prerequisites + +- Historical source branch `pr-2050` at `1bb9e9a3`, or the signed review + branch `2050-i2p-peer-support-reviewed` when reproducing the review baseline +- Rust toolchain installed (MSRV 1.88) +- Tracker built with `cargo build` +- Storage directories created + +--- + +## Test 1: Announce an I2P peer (non-compact) + +**Goal**: Verify that an I2P Destination in the `ip` parameter is accepted +and stored. + +**Command**: + +```bash +# Generate a valid I2P Destination (516 chars Base64 + .i2p suffix) +# Using a padded Destination with 391 decoded bytes (387 + 4 cert payload) +# cspell:disable-next-line +I2P_DEST="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" +PEER_ID="-QT0001-000000000001" + +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=${PEER_ID}&port=1&ip=${I2P_DEST}&uploaded=0&downloaded=0&left=0&compact=0" +``` + +**Expected**: Tracker accepts the announce and returns a response with +`peers: []` (no other peers yet). + +**Observed output**: + +```text +Destination length: 528 +# cspell:disable-next-line +d8:completei1e10:incompletei0e8:intervali120e12:min intervali120e5:peerslee +``` + +**Result**: **PASS** — an exactly generated Destination with raw `==` padding +is accepted. Responses are bencoded, so `python3 -m json.tool` is not an +appropriate response decoder. + +**Important limitation**: sending the same padding as `%3D%3D` is rejected; +see Test 5. + +--- + +## Test 2: Announce a second I2P peer — verify I2P-to-I2P matchmaking + +**Goal**: Verify that two I2P peers on the same torrent see each other +in the response. + +**Command**: + +```bash +# cspell:disable-next-line +I2P_DEST_1="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +# cspell:disable-next-line +I2P_DEST_2="$(python3 -c "print('B' + 'A' * 511 + 'BQAEAAAAAA==.i2p')")" +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +# Announce first I2P peer +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=${I2P_DEST_1}&uploaded=0&downloaded=0&left=0&compact=0" > /dev/null + +# Announce second I2P peer — response must contain peer 1's Destination. +response="$(curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000002&port=1&ip=${I2P_DEST_2}&uploaded=0&downloaded=0&left=0&compact=0")" +if printf '%s' "${response}" | grep -Fq "${I2P_DEST_1}"; then + printf 'I2P-to-I2P: destination-present=yes, response-bytes=%s\n' "${#response}" +else + printf 'I2P-to-I2P: destination-present=no, response-bytes=%s\n' "${#response}" +fi +``` + +**Expected**: Response contains one peer with: + +- `ip` field = full I2P Destination string of peer 1 +- `port` = 1 (placeholder) +- `peer_id` = peer 1's peer ID + +**Observed output**: + +```text +I2P-to-I2P: destination-present=yes, response-bytes=654 +``` + +**Result**: **PASS** — an I2P requester receives another I2P peer in a +non-compact response. + +--- + +## Test 3: Clearnet client does not receive I2P peers + +**Goal**: Verify that a clearnet announce does not return I2P peers. + +**Command**: + +```bash +# cspell:disable-next-line +I2P_DEST="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +# Announce an I2P peer +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=6881&ip=${I2P_DEST}&uploaded=0&downloaded=0&left=0&compact=0" > /dev/null + +# Announce a clearnet peer — response must not contain the I2P Destination. +response="$(curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000002&port=6881&uploaded=0&downloaded=0&left=0&compact=0")" +if printf '%s' "${response}" | grep -Fq "${I2P_DEST}"; then + printf 'Clearnet-to-I2P: destination-present=yes, response-bytes=%s\n' "${#response}" +else + printf 'Clearnet-to-I2P: destination-present=no, response-bytes=%s\n' "${#response}" +fi +``` + +**Expected**: Response should NOT contain the I2P peer. + +**Observed output**: + +```text +Clearnet-to-I2P: destination-present=no, response-bytes=75 +``` + +**Result**: **PASS** — peers are filtered by address kind in +`Coordinator::peers_excluding()`. The original review finding that the PR +mixed I2P and clearnet responses was incorrect. + +--- + +## Test 4: I2P compact response contains 32-byte hashes + +**Goal**: Verify that an I2P compact response uses 32-byte Destination hashes. + +**Command**: + +```bash +# cspell:disable +I2P_DEST_1="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +I2P_DEST_2="$(python3 -c "print('B' + 'A' * 511 + 'BQAEAAAAAA==.i2p')")" +# cspell:enable +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +# Announce an I2P peer +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=${I2P_DEST_1}&uploaded=0&downloaded=0&left=0&compact=0" > /dev/null + +# Announce a second I2P peer with compact=1 — inspect raw response +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000002&port=1&ip=${I2P_DEST_2}&uploaded=0&downloaded=0&left=0&compact=1" -o /tmp/compact_response.bin + +# Decode only the bencoded `peers` byte string using the Python standard library. +python3 - <<'PY' +from pathlib import Path + +payload = Path('/tmp/compact_response.bin').read_bytes() +start = payload.index(b'5:peers') + len(b'5:peers') +length_end = payload.index(b':', start) +length = int(payload[start:length_end]) +peers = payload[length_end + 1 : length_end + 1 + length] +assert len(peers) == length +print(f'peers_length={len(peers)}, mod_32={len(peers) % 32}') +print(f'sha256_hash_payload={peers.hex()}') +PY +``` + +**Observed output**: + +```text +peers_length=32, mod_32=0 +sha256_hash_payload=19356c32b7979ecf6541a5233085564f2ec55578d603c520b49ccc459f758abc +``` + +**Result**: **PASS** — the I2P compact response contains exactly one 32-byte +Destination hash. A clearnet compact response after an I2P announce returned +an empty `peers` payload (`peers_length=0`), confirming separation. + +--- + +## Test 5: Query parser — Base64 padding in `ip` parameter (finding 3.3) + +**Goal**: Verify that `=` characters in the `ip` parameter are preserved +(not split). + +**Command**: + +```bash +# I2P Destination with == padding +# cspell:disable-next-line +I2P_DEST="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +echo "Destination: ${I2P_DEST}" +echo "Length: ${#I2P_DEST}" + +# Announce with raw padding — should succeed. Responses are bencoded. +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=${I2P_DEST}&uploaded=0&downloaded=0&left=0&compact=0" +``` + +**Expected**: Both raw `==` and URL-encoded `%3D%3D` padding should be accepted +after normal HTTP query decoding. + +**Observed output**: + +```text +# cspell:disable-next-line +Raw-padding response: d8:completei1e10:incompletei0e8:intervali120e12:min intervali120e5:peerslee +Percent-encoded-padding response: d14:failure reason... invalid param value ...%3D%3D.i2p for ipe +``` + +**Result**: **FAIL** — raw padding is accepted, but percent-encoded padding is +rejected because the custom query parser does not percent-decode values before +I2P Destination parsing. This is the confirmed URL-encoding interoperability +finding in `review-pass-1.md`. + +**Required regression test**: Add a protocol-level test that parses a query +with `ip` set to the exact valid fixture + + + +`"A".repeat(512) + "BQAEAAAAAA%3D%3D.i2p"` and asserts that it produces +`Some(AnnounceAddress::I2p(_))`. The existing disabled test must be restored +and committed with the percent-decoding implementation fix. + +--- + +## Test 6: Invalid I2P Destination is rejected + +**Goal**: Verify that invalid I2P Destinations (wrong Base64, too short) +are rejected. + +**Command**: + +```bash +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +# Too short (384 decoded bytes = 512 Base64 chars, below 387 minimum) +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=$(python3 -c "print('A' * 512 + '.i2p')")&uploaded=0&downloaded=0&left=0&compact=0" + +# Invalid Base64 characters +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=$(python3 -c "print('!' * 516 + '.i2p')")&uploaded=0&downloaded=0&left=0&compact=0" +``` + +**Expected**: Both return error responses (invalid parameter). + +**Observed output**: + +```text +=== short Destination === +d14:failure reason... invalid param value ... .i2p for ipe +=== invalid Destination === +d14:failure reason... invalid param value !!!...!.i2p for ipe +``` + +**Result**: **PASS** — a too-short Destination and a Destination using an +invalid I2P Base64 character are rejected with bencoded failure responses. + +--- + +## Test 7: UDP handler — I2P peers excluded + +**Goal**: Verify that UDP responses do not include I2P peers. + +**Command**: + +```bash +# This requires the tracker_client binary (UDP announce) +# First announce an I2P peer via HTTP, then query via UDP +# cspell:disable-next-line +I2P_DEST="$(python3 -c "print('A' * 512 + 'BQAEAAAAAA==.i2p')")" +INFO_HASH="%9C8B%22%13%E3%0B%FF%21%2B0%C3%60%D2o%9A%02%13d%22" + +# Announce I2P peer via HTTP +curl -s "http://127.0.0.1:7070/announce?info_hash=${INFO_HASH}&peer_id=-QT0001-000000000001&port=1&ip=${I2P_DEST}&uploaded=0&downloaded=0&left=0&compact=0" > /dev/null + +# Query via UDP — I2P peer should NOT appear +cargo run -p torrust-tracker-client --bin tracker_client -- udp announce "udp://127.0.0.1:6969" "${INFO_HASH}" +``` + +**Expected**: UDP response has empty `peers` list (I2P peer filtered out). + +**Observed output**: + +```json +{ + "AnnounceIpv4": { + "transaction_id": -888840697, + "announce_interval": 120, + "leechers": 0, + "seeders": 2, + "peers": [] + } +} +``` + +**Result**: **PARTIAL** — the UDP peer list correctly contains no I2P peer. +However, `seeders: 2` includes unreachable I2P peers from aggregate swarm +metadata. This is the unresolved network-scoped-statistics defect in finding +3.12 / action A5 of `review-pass-1.md`; it must be fixed before merge. + +--- + +## Summary + +| Test | Description | Finding | Status | +| ---- | ---------------------------- | ------------------------------------------------ | ------- | +| 1 | I2P announce accepted | Destination parsing | PASS | +| 2 | I2P-to-I2P matchmaking | Swarm stores I2P peers | PASS | +| 3 | Clearnet excludes I2P peers | Cross-network separation | PASS | +| 4 | I2P 32-byte compact hash | I2P compact wire format | PASS | +| 5 | Base64 padding URL encoding | `%3D%3D` compatibility | FAIL | +| 6 | Invalid Destination rejected | Validation works | PASS | +| 7 | UDP excludes I2P peers | Peer filter passes; aggregate statistics need A5 | PARTIAL | + +--- + +## Focused Automated Tests + +**Command**: + +```bash +cargo test -p torrust-tracker-primitives \ + -p torrust-tracker-http-protocol \ + -p torrust-tracker-http-core \ + -p torrust-tracker-axum-http-server \ + --lib +``` + +**Result**: **PASS**. + +| Package | Tests | +| ---------------------------------- | --------------------------------------------------------------: | +| `torrust-tracker-http-core` | 21 passed | +| `torrust-tracker-http-protocol` | 52 passed | +| `torrust-tracker-primitives` | 32 passed | +| `torrust-tracker-axum-http-server` | No unit-test count recorded; rerun required for an exact result | + +The run included Destination validation, announce parsing, and +compact/non-compact encoding tests. It did not directly select +`torrust-tracker-swarm-coordination-registry`; Tests 2–3 provide the manual +matchmaking evidence. The HTTP protocol tests do not cover percent-encoded +`%3D%3D` padding, which matches the manually reproduced interoperability +failure. + +--- + +## Environment + +| Item | Value | +| --------------- | ------------------------------------------------------------------------------------ | +| Source baseline | Historical `pr-2050` at `1bb9e9a3`; review baseline `2050-i2p-peer-support-reviewed` | +| Rust toolchain | _(fill in after `rustup show`)_ | +| Tracker config | `share/default/config/tracker.development.sqlite3.toml` | +| HTTP port | 7070 | +| UDP port | 6969 | +| Test date | 2026-08-17 | +| Tester | Jose Celano + AI agent | diff --git a/docs/pr-reviews-manual/pr-2050/review-pass-1.md b/docs/pr-reviews-manual/pr-2050/review-pass-1.md new file mode 100644 index 000000000..d7cf9cff4 --- /dev/null +++ b/docs/pr-reviews-manual/pr-2050/review-pass-1.md @@ -0,0 +1,596 @@ +--- +semantic-links: + pr: "https://github.com/torrust/torrust-tracker/pull/2050" + superseding-pr: "https://github.com/torrust/torrust-tracker/pull/2059" + pr-title: "Feat/i2p peer support" + pr-author: "Frigyes06" + pr-branch: "feat/i2p-peer-support → develop" + pr-stats: "+925 / -355, 45 files, 2 commits" + i2p-spec: "https://i2p.net/en/docs/applications/bittorrent/" + related-artifacts: + - docs/packages.md + - packages/primitives/src/i2p.rs + - packages/http-protocol/src/v1/responses/announce/encoding.rs +--- + +# PR #2050 — I2P Peer Support + +Manual review of [PR #2050](https://github.com/torrust/torrust-tracker/pull/2050) +"Feat/i2p peer support" by [Frigyes06](https://github.com/Frigyes06). + +> **Review status (2026-08-18):** PR #2050 and commit `1bb9e9a3` are the +> historical source reviewed in this report. The signed review baseline is +> `2050-i2p-peer-support-reviewed`; the active proposed implementation is +> draft [PR #2059](https://github.com/torrust/torrust-tracker/pull/2059). +> Findings and merge gates below apply to PR #2059 unless a section explicitly +> identifies the historical PR #2050 source. + +--- + +## 1. PR Overview + +Adds I2P anonymous network peer support to the HTTP tracker. I2P clients can +announce using their Base64 Destination in the `ip` query parameter, and the +tracker matchmakes I2P peers. The goal is I2P/Internet isolation — I2P peers +should not be mixed with clearnet peers in responses. + +### Commits + +| SHA | Message | +| ---------- | ----------------------------------------------- | +| `0f720738` | I2P support | +| `1bb9e9a3` | feat(http-tracker): support I2P peers (rebased) | + +### Scope + +- HTTP tracker announce parsing, encoding, and response generation +- Swarm peer-address model (new `PeerAddress` enum) +- UDP handler filtering (I2P peers excluded from UDP responses) +- REST API adapter adjustments (references instead of by-value) +- Documentation and tests + +--- + +## 2. Architecture Changes + +### 2.1 New `I2pDestination` type (`packages/primitives/src/i2p.rs`) + +Parses and validates I2P Base64 Destinations: + +- Custom I2P Base64 alphabet (`A-Za-z0-9-~`) +- Minimum 387 decoded bytes, certificate length validation +- SHA-256 hash computed at parse time for compact responses +- Normalizes suffix (strips `.i2p`/`.I2P` for storage, re-adds on display) + +**Assessment**: Solid implementation. Validation covers length, Base64 charset, +and certificate consistency. + +### 2.2 `PeerAddress` enum (`packages/primitives/src/peer.rs`) + +```rust +pub enum PeerAddress { + Clearnet(SocketAddr), + I2p(I2pPeerAddress), +} +``` + +Replaces `SocketAddr` in the `peer_addr` field of `Peer`. This is the +**highest-impact change** — it ripples through the entire codebase. + +Helpers: `port()`, `ip() → Option`, `socket_addr() → Option`, +`is_i2p()`. Implements `Ord`, `Hash`, `Eq` for use as `BTreeMap` key. + +**Assessment**: Clean design. The `ip()` returning `Option` forces callers to +handle the I2P case explicitly — good. + +### 2.3 HTTP announce request parsing + +New `AnnounceAddress` enum in `packages/http-protocol/src/v1/requests/announce.rs`: + +```rust +pub enum AnnounceAddress { + Ip(IpAddr), + I2p(I2pDestination), +} +``` + +The `extract_ip()` function now tries `IpAddr` first, then `I2pDestination`. +Invalid `.i2p` suffixes are rejected with a clear error. + +**Query parser fix**: `split_once('=')` replaces `split('=').collect()` — values +containing `=` (like I2P Base64 padding `==`) are now preserved correctly. This +is also a bugfix for existing non-I2P behavior. + +**Assessment**: Good. The fallback logic (try IP, then I2P, then ignore) is +correct. The query parser fix is a genuine improvement. + +### 2.4 HTTP response encoding + +**Non-compact**: `NormalPeer.ip` is now `String` (was `IpAddr`). I2P peers +serialize their full Destination string; clearnet peers serialize their IP as +a string. Port is `I2P_PLACEHOLDER_PORT = 1` for I2P. + +**Compact**: New `CompactPeer::I2p([u8; 32])` variant. For an I2P requester, +the response encodes each returned I2P peer as its 32-byte SHA-256 hash in the +`peers` byte string. + +**Assessment**: Encoding is correct per the I2P BitTorrent spec. Manual +verification confirmed an I2P-only compact response with a 32-byte `peers` +payload. See Section 3 for the URL-encoding interoperability finding. + +### 2.5 Swarm registry + +`BTreeMap>` — the key change from `SocketAddr` to +`PeerAddress` prevents key collisions between I2P and clearnet peers. Peer +inactivity cleanup operates on `PeerAddress` correctly (Ord/Hash implemented). + +### 2.6 UDP handler filtering + +UDP announce response builder uses `peer.peer_addr.ip()` with `filter_map` — +I2P peers return `None` for `ip()` and are excluded. This is appropriate for +the HTTP-only scope of this PR. UDP-over-I2P was standardized in 2025-06 and +is covered by a separate I2P specification. + +--- + +## 3. Key Findings + +### 3.1 ✅ I2P and clearnet peers are isolated in swarm responses + +The swarm coordinator filters peers by address kind before returning them: + +```rust +.filter(|peer| peer.peer_addr.is_i2p() == peer_addr.is_i2p()) +``` + +Manual verification confirms that an I2P requester received another I2P peer, +while a subsequent clearnet request contained no I2P Destination and an empty +compact `peers` payload. This satisfies the I2P cross-network prevention +requirement. The initial review conclusion that peers were mixed was incorrect. + +### 3.2 ⚠️ Percent-encoded Base64 padding is rejected + +I2P Destinations can contain Base64 padding (`==`). A valid Destination with +raw padding is accepted, but the same value with URL-safe `%3D%3D` padding is +rejected. The custom query parser preserves `%3D` literally instead of +percent-decoding it before I2P Destination parsing. + +This is an interoperability issue: a compliant HTTP client may encode reserved +`=` characters in query parameter values. The parser should URL-decode query +parameter values before parsing the I2P Destination. + +### 3.3 ⚠️ Client-side compact deserialization is not I2P-aware + +`DeserializedCompactParsed` in `deserialization.rs` assumes fixed 6-byte chunks: + +```rust +for peer_bytes in compact_announce.peers.chunks_exact(6) { + peers.push(CompactPeer::new_from_bytes(peer_bytes)); +} +``` + +A 32-byte I2P hash is not parsed correctly by this 6-byte IPv4-oriented path. +The test `it_should_return_i2p_destination_hashes_in_a_compact_response` works +around this by comparing raw bytes (`announce.peers == *hash`) rather than +going through the deserialization path. + +### 3.4 ✅ Query parser bugfix (unrelated to I2P) + +`split_once('=')` replacing `split('=').collect()` is a genuine improvement. +Previously, `name=value=value` was rejected; now `name=value==` is correctly +parsed as value `value==`. This is relevant for Base64 padding but also +benefits any parameter value containing `=`. + +### 3.5 ✅ I2P Destination validation is thorough + +- Base64 alphabet check +- Minimum length (387 decoded bytes) +- Certificate payload length consistency +- SHA-256 hash computed at parse time (not on every response) + +### 3.6 ✅ UDP filtering is correct + +The UDP handler correctly excludes I2P peers. No changes needed there. + +### 3.7 ⚠️ Tracker client does not support I2P announces + +The `tracker_client` binary (`console/tracker-client`) accepts `--ip` as an +`IpAddr` parameter. It cannot send I2P Destinations in the `ip` query +parameter. This means: + +- Manual testing of I2P announces requires raw `curl` commands +- The client should be updated in a follow-up PR to support `--i2p-destination` + or accept a string `--ip` parameter + +This is not a blocker for the PR, but should be documented as a known +limitation and tracked as a follow-up issue. + +### 3.8 ⚠️ Interaction with issue #1987 must be specified + +Issue [#1987](../../issues/open/1987-add-config-option-to-use-ip-from-announce-query-string/ISSUE.md) +plans an opt-in setting for trusting a clearnet IP supplied through the `ip` +query parameter. PR #2050 deliberately creates a protocol-specific exception: +a valid I2P Destination must be used even while that setting is disabled. + +The eventual precedence must be explicit: + +1. A valid I2P Destination is always used as an I2P peer address. +2. A valid clearnet IP is used only when the future opt-in setting is enabled. +3. Otherwise, use the resolved connection IP. + +The #1987 specification now records this rule and requires an ADR when that +issue is implemented. This PR should link to #1987 and document the exception. + +### 3.9 ⚠️ I2P parse errors lose useful diagnostic context + +`I2pDestination::from_str` exposes structured errors that distinguish invalid +I2P Base64, an undersized Destination, and an inconsistent certificate length. +However, `extract_ip()` discards those errors and, when the value ends in +`.i2p`, returns the generic `ParseAnnounceQueryError::InvalidParam` error. + +This does not meet the error-handling convention's clarity and actionability +goals: an I2P client cannot tell whether it must correct Base64 encoding, +provide a full Destination, or correct certificate data. Preserve the +structured I2P parse error as the source of an I2P-specific announce error, or +include its reason in the user-facing failure response. + +### 3.10 ⚠️ Destination structure validation is incomplete and input is unbounded + +`I2pDestination::from_str` decodes and hashes the complete untrusted query +value without a maximum encoded or decoded length. On failure, +`ParseAnnounceQueryError::InvalidParam` includes that complete value in the +bencoded HTTP failure response. This can amplify a large request into memory, +CPU, and response/log output work. + +The I2P common-structures specification defines a Destination as `KeysAndCert`: +384 key/padding bytes followed by a Certificate whose type and payload length +must be valid. The implementation checks only the declared certificate length; +it accepts arbitrary certificate types and payloads when their lengths match. +The specification explicitly cautions implementers to prohibit excess data and +enforce the appropriate length for each certificate type. + +Add a compatibility-aware maximum before Base64 decoding, validate supported +certificate types and their payload structure, reject unsupported types +explicitly, and avoid echoing full untrusted input. Do **not** impose the I2P +BitTorrent page's current $475$-byte "reasonable maximum" as a universal hard +limit: modern valid Key Certificates may contain excess key data and exceed it. + +### 3.11 ⚠️ REST peer-address contract is undocumented and untested for I2P + +The REST adapter safely serializes `PeerAddress` using `to_string()`, so an I2P +peer is not dropped or converted into a misleading clearnet socket address. It +is returned as its full normalized Destination. However, the public REST DTO +documents `peer_addr` as "The peer's socket address" and gives only an +`IP:port` example. No REST adapter or endpoint contract test covers an I2P +peer. + +Do not silently redefine this existing field as a polymorphic endpoint string. +That would produce partial I2P support with an ambiguous REST contract. The +initial I2P work must instead document the intended final REST behavior: I2P +peers should be returned separately from clearnet peers in an additive, +explicitly typed JSON collection. The API redesign and migration belong in the +[REST API overhaul epic #144](https://github.com/torrust/torrust-tracker/issues/144), +with an ADR agreed before implementation. + +### 3.12 ⚠️ Announce statistics are not filtered by peer network + +`Coordinator::peers_excluding()` correctly filters the returned peer list by +`PeerAddress::is_i2p()`, but `Coordinator::metadata()` remains aggregate across +both clearnet and I2P peers. The announce response obtains `complete` and +`incomplete` from that aggregate metadata. Consequently, an I2P requester can +receive no usable peers while being told that the swarm has clearnet seeders or +leechers (and vice versa). + +This produces misleading announce statistics after network isolation. Return +network-scoped metadata using the same address-kind criterion as peer selection, +while retaining aggregate metadata for administrative/statistics APIs where it +is explicitly intended. + +### 3.13 ⚠️ Malformed suffixless Destinations silently fall back to clearnet + +The I2P protocol permits a Destination without the `.i2p` suffix. In +`extract_ip()`, a non-IP value is rejected only when it has that suffix. A +malformed or truncated suffixless Destination therefore returns `Ok(None)`, and +the announce service registers the requester as a clearnet peer using its +transport address. That silently crosses the network boundary the PR is meant +to enforce. + +Define an unambiguous suffixless-I2P candidate rule and reject candidates that +fail I2P validation. At minimum, values that use the I2P Base64 alphabet and +are destination-sized must not silently fall back to clearnet behavior. + +### 3.14 ⚠️ Destination identity is spoofable without trusted transport context + +A normal HTTP request does not prove that the requester owns the I2P +Destination supplied in the `ip` parameter. An attacker can claim a victim's +Destination, receive I2P swarm information, and update or remove the victim's +peer record because the swarm is keyed by `PeerAddress`. + +Serving the tracker only through I2P is not sufficient: it establishes that +_some_ I2P client made the request but not that it owns the Destination claimed +in the query. The tracker must derive identity from trusted I2P transport +context (for example, a validated server-tunnel `X-I2P-DestB64` header or a +SAMv3/I2CP adapter) and reject or ignore mismatching query values. See the +[destination spoofing analysis](destination-spoofing-analysis.md) for the +attack scenario, deployment trade-offs, and minimum secure policies. + +--- + +## 4. Actions for the Contributor + +### Required before merge + +- [ ] **A1**: **URL-decode I2P Destination query values** — A valid Destination + with percent-encoded Base64 padding (`%3D%3D`) is rejected, while the + raw-padding form succeeds. Decode query parameter values before I2P + Destination parsing. + - **Implementation area**: `packages/http-protocol/src/v1/query.rs` and `packages/http-protocol/src/v1/requests/announce.rs`. + - **Required behavior**: Decode percent-encoded query parameter names and values exactly once before protocol parsing. Do not decode a second time or change raw binary `info_hash` and `peer_id` handling. + - **Regression test**: Restore the existing disabled + `it_should_parse_a_percent_encoded_padded_i2p_destination_from_the_ip_param` + test and make it pass. It must parse + + `"A".repeat(512) + "BQAEAAAAAA%3D%3D.i2p"` as `Some(AnnounceAddress::I2p(_))`. + - **Keep existing coverage**: Preserve the raw-padding `==` test and add a query-level test showing `%3D` decodes to `=`. + - **Completion criteria**: Raw and percent-encoded padding produce the same normalized `I2pDestination`; malformed percent escapes return a structured error; focused protocol tests and `linter all` pass. + +- [ ] **A2**: **Deserialization fix** — `DeserializedCompactParsed` uses + `chunks_exact(6)` and cannot parse a valid 32-byte I2P compact peer hash. + Extend the client-side response types or explicitly separate them from + the clearnet-only parsed compact representation. + - **Implementation area**: `packages/http-protocol/src/v1/responses/announce/deserialization.rs` and `packages/http-protocol/src/v1/responses/announce/encoding.rs`. + - **Required behavior**: Do not parse a 32-byte I2P hash as IPv4 peers. Represent I2P compact responses explicitly, or make the clearnet parser reject I2P-format payloads with a clear error. + - **Regression tests**: Parse a valid 32-byte I2P compact `peers` payload into the correct I2P representation and prove it cannot be interpreted as IPv4 entries. + - **Completion criteria**: The client-side representation has an unambiguous I2P path, invalid compact lengths do not panic or silently truncate, and IPv4/IPv6 compact tests continue to pass. + +- [ ] **A3**: **Preserve I2P parse-error context** — Do not collapse + `ParseI2pDestinationError` into generic `InvalidParam` for `.i2p` + values. Return an I2P-specific error with the underlying reason so users + can correct invalid Base64, insufficient length, or certificate data. + - **Implementation area**: `packages/http-protocol/src/v1/requests/announce.rs` and `packages/primitives/src/i2p.rs`. + - **Required behavior**: Add an I2P-specific `ParseAnnounceQueryError` variant retaining `ParseI2pDestinationError` as source and identifying `ip`. Map it to a concise failure reason without reflecting the full Destination. + - **Regression tests**: Cover invalid I2P Base64, too-short Destination, and invalid certificate length. Assert the structured source and a useful bounded failure message. + - **Completion criteria**: The three modes remain distinguishable, errors answer what and why, and no production `unwrap()` is added to the parsing path. + +- [ ] **A4**: **Bound and redact invalid Destination input** — Set a documented + compatibility-aware maximum I2P Destination size before Base64 decoding. + Do not reflect the full untrusted `ip` value in the error response; return + an I2P-specific, actionable reason instead. Add boundary tests. + - **Implementation area**: `packages/primitives/src/i2p.rs` and announce error mapping in `packages/http-protocol/src/v1/requests/announce.rs`. + - **Required behavior**: Check encoded input length before allocating the decoded Base64 buffer. Validate Certificate type at byte $384$, declared payload length at bytes $385$–$386$, and payload structure for supported types. Reject unsupported types and excess certificate data. The limit must allow all supported modern key types; $475$ decoded bytes is not a safe universal bound. + - **Error behavior**: Errors may identify parameter and bounded actual/maximum lengths, but must not include the full Destination. + - **Regression tests**: Cover NULL and every supported Key Certificate layout, unsupported type, declared-length mismatch, selected maximum, one-character overflow, and oversized-input redaction. + - **Completion criteria**: No unbounded decode/hash occurs, only structurally valid supported Destinations are accepted, errors are bounded/actionable, and supported types/limits are documented with their specification rationale. + +- [ ] **A5**: **Return network-scoped announce statistics** — `complete` and + `incomplete` must describe peers reachable by the requesting network. + - **Implementation area**: `packages/swarm-coordination-registry/src/swarm/coordinator.rs`, registry/repository query methods, and the tracker-core announce response assembly. + - **Required behavior**: Derive response metadata using the same `is_i2p()` filter as `peers_excluding()`. Keep aggregate metadata only for administrative and aggregate-statistics consumers that intentionally span both networks. + - **Regression tests**: Build a swarm with an I2P leecher and a clearnet seeder sharing one info hash. Assert that an I2P announce receives no clearnet peer and reports zero reachable seeders; assert the reciprocal clearnet case; assert same-network counts remain correct. + - **Completion criteria**: Peer list and `complete`/`incomplete` response fields are internally consistent for both networks, without regressing aggregate scrape/management statistics. + +- [ ] **A6**: **Reject malformed suffixless I2P candidates** — Do not treat a + malformed suffixless I2P Destination as an absent `ip` parameter. + - **Implementation area**: `packages/http-protocol/src/v1/requests/announce.rs` and `packages/http-protocol/src/v1/query.rs`. + - **Required behavior**: Define a documented candidate rule for suffixless I2P Destinations. If a non-IP value meets that rule but fails `I2pDestination` validation, return an I2P-specific announce parse error; do not fall back to the TCP/X-Forwarded-For address. + - **Regression tests**: Cover a valid suffixless Destination, a truncated suffixless candidate, an invalid-alphabet suffixless candidate, an ordinary non-I2P value, and a valid clearnet IP. Verify only the ordinary non-I2P value retains existing ignore behavior, if that compatibility behavior is retained. + - **Completion criteria**: A malformed suffixless Destination cannot cause a clearnet registration or expose clearnet peers to an intended I2P announce. + +- [ ] **A7**: **Eliminate Destination spoofing before enabling I2P announces** — + Do not use an untrusted HTTP `ip` query value as an authenticated I2P peer + identity. Select and implement one of the safe policies defined in the + [destination spoofing analysis](destination-spoofing-analysis.md): + trusted I2P transport identity enforcement on a dedicated listener, or + rejection of I2P announces until enforcement exists. + - **Current status**: Neither policy is implemented in PR #2059. The draft + must not merge while public I2P announces remain enabled with an untrusted + query Destination. + - **Implementation area**: HTTP tracker listener configuration, trusted + reverse-proxy/tunnel context extraction, and + `packages/http-core/src/services/announce.rs`. + - **Required behavior**: In I2P-enforced mode, derive `PeerAddress::I2p` + from a validated trusted I2P transport identity. Reject direct/untrusted + requests, missing or malformed identity headers, and mismatches between the + query `ip` Destination and the trusted identity. Keep clearnet and + I2P-enforced listeners on separate explicit policies. + - **Regression tests**: Cover trusted and untrusted proxy sources; absent and + malformed identity headers; matching and mismatching query Destinations; + direct listener access; and attempted peer-record takeover. + - **Completion criteria**: An attacker cannot obtain I2P peer information or + update a victim peer record by claiming the victim's Destination. + +### Follow-up work or documentation + +- [ ] **F1**: **Query parser regression test** — The `split_once('=')` fix + changes behavior for `name=value=value` (now accepted instead of rejected). + Retain a test documenting this intentional change. Add a query-level + assertion that the first `=` separates the name and value while later + `=` characters remain part of the decoded value. + +- [ ] **F2**: **Placeholder port documentation** — The I2P spec says: + _"Clients generally include a fake port=6881 parameter... Trackers may + ignore the port parameter, and should not require it."_ Document why + `I2P_PLACEHOLDER_PORT` is `1` (not `6881`) and that this value is + conventional. The documentation must state that I2P routes by Destination, + clients must ignore the response port, and `1` exists only for legacy + non-compact peer dictionary compatibility. + + **Follow-up numbering note**: F3 was promoted to required action A5 when the + manual UDP check confirmed that aggregate announce statistics violate network + isolation. The remaining follow-up identifiers preserve the review history. + +- [ ] **F4**: **Tracker client I2P support** — The `tracker_client` binary + (`console/tracker-client`) accepts `--ip` as `IpAddr` only. It cannot + send I2P Destinations. Add a comment in the PR noting this limitation + and track a follow-up issue to add `--i2p-destination` support to the + client. The follow-up should accept raw or percent-encoded Destinations, + validate through `I2pDestination`, and parse both I2P non-compact and + compact responses without treating hashes as clearnet addresses. + +- [ ] **F5**: **Define REST API I2P representation in epic #144** — Do not + change the current `peer_addr` socket-address field to carry I2P + Destinations. Add an ADR under the + [REST API overhaul epic #144](https://github.com/torrust/torrust-tracker/issues/144) + that defines the final, versioned JSON contract before implementation. + - **Expected final behavior**: Return clearnet and I2P peers in separate, + explicitly typed collections. An I2P entry must identify its Destination + (for non-compact management responses) without inventing an IP address or + a meaningful port. + - **ADR decision points**: Resource names; versioning/migration strategy; + whether the I2P collection is optional, paginated, or separately queried; + authorization/privacy implications of exposing full Destinations; and + backwards compatibility for existing REST clients. + - **Implementation gate**: No REST I2P representation should be added until + the ADR is accepted and the epic's API migration plan includes contract, + adapter, endpoint, and client tests. + +--- + +## 5. Spec-Informed Answers to Our Questions + +Cross-referenced with the [I2P BitTorrent specification](https://i2p.net/en/docs/applications/bittorrent/). + +### Q1: Swarm isolation model — separate swarms or filtering at response layer? + +**Spec answer**: Filtering at the response layer is sufficient. The spec says: + +> "Trackers should reject standard network announces with IPv4 or IPv6 IPs, +> and not deliver them in responses." + +The spec does not require separate swarms — it requires that **I2P peers are +not delivered in clearnet responses** and vice versa. The tracker stores all +peers in one swarm and filters at encode time. + +**Implication for PR #2050**: The PR stores I2P and clearnet peers in the same +`BTreeMap` and filters returned peers by `is_i2p()` in the +swarm coordinator. Manual verification confirmed that clearnet and I2P +responses are isolated, satisfying this requirement. + +### Q2: Destination enforcement via X-I2P-Dest\* headers + +**Spec answer**: Optional but recommended, and expected to become universal: + +> "Trackers may choose to prevent spoofing by requiring this, and verifying the +> client's Destination using HTTP headers added by the I2PTunnel HTTP Server +> tunnel... Unfortunately, as the network grows, so will the amount of +> maliciousness, so we expect that all trackers will eventually enforce +> destinations." + +The headers (`X-I2P-DestHash`, `X-I2P-DestB64`, `X-I2P-DestB32`) cannot be +spoofed by the client. A tracker enforcing destinations "need not require the +`ip` announce parameter at all." + +**Implication for PR #2050**: This blocks merge. Without enforcement, any +client can spoof I2P announces by passing a valid Destination in `ip`. +Implement the minimum secure policy described in the +[destination spoofing analysis](destination-spoofing-analysis.md) before +enabling I2P announces. + +### Q3: Compact response format — separate field or mixed? + +**Spec answer**: The spec is clear — the compact response `peers` key should +be a **single byte string of concatenated 32-byte SHA-256 hashes**: + +> "In the compact response, the value of the 'peers' dictionary key is a +> single byte string, whose length is a multiple of 32 bytes. This string +> contains the concatenated 32-byte SHA-256 Hashes of the binary Destinations +> of the peers." + +**Critical finding**: The spec describes an **I2P-only** compact response. +The `peers` field should contain ONLY 32-byte I2P hashes — not mixed with +IPv4 (6-byte) or IPv6 (18-byte) entries. This is a different wire format than +standard BEP 23 compact responses. + +**Implication for PR #2050**: The coordinator filters by address kind before +encoding. Manual verification confirmed that an I2P compact response contained +only a 32-byte hash and that a clearnet compact response contained no I2P +hashes. This means: + +- A clearnet client should never see I2P hashes in `peers` +- An I2P client should only see 32-byte hashes in `peers` +- The tracker needs to know the requester's network type to choose the right + encoding + +### Q4: UDP over I2P + +**Spec answer**: The UDP-over-I2P specification was finalized in 2025-06. It +is separate from BEP 15 and defines its own +[UDP announce protocol](https://i2p.net/en/docs/specs/udp-announces). + +**Implication for PR #2050**: HTTP-only scope is correct for now. UDP I2P +support is a separate, future effort. + +### Q5: Testing — raw bytes vs. deserialization + +**Spec answer**: Not directly addressed, but the spec says compact responses +are "a single byte string, whose length is a multiple of 32 bytes." The +current `DeserializedCompactParsed` (which uses `chunks_exact(6)`) is +incompatible with I2P compact responses. + +**Implication for PR #2050**: The test comparing raw bytes is correct for +validating the wire format, but the deserialization path needs to be updated +to handle I2P compact responses (32-byte chunks, not 6-byte). + +### Q6: `max_peers_per_announce` — separate or shared? + +**Spec answer**: The spec does not address this. The `numwant` parameter is +the same as standard bittorrent. + +**Implication for PR #2050**: Currently shared. This is acceptable per spec, +but may need clarification in documentation. + +### Q7: PEX / DHT over I2P + +**Spec answer**: Both are specified: + +- **PEX**: Extension message `i2p_pex`, uses 32-byte SHA-256 hashes (same + format as compact response) +- **DHT**: Extension message `i2p_dht`, compact node info is 54 bytes + (20-byte Node ID + 32-byte hash + 2-byte port). Requires SAM v3.3 + PRIMARY and SUBSESSIONS. + +**Implication for PR #2050**: Not required for this PR, but the spec explicitly +defines these. Should be documented as future work. + +--- + +## 6. Review Progress + +| Date | Reviewer | File | Status | +| ---------- | ---------------------- | ------------------------------ | --------------------------------------------------- | +| 2026-08-17 | Jose Celano + AI agent | `review-pass-1.md` (this file) | Findings carried to draft PR #2059 | +| 2026-08-17 | Jose Celano + AI agent | `review-pass-1.md` §5 | Spec cross-reference complete (I2P BitTorrent spec) | + +### Review Work Tracker + +| ID | Review activity | Status | Evidence / next step | +| --- | ----------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | HTTP I2P announce and response behavior | Complete | `manual-test-evidence.md` Tests 1–7 | +| R2 | Cross-network isolation and compact wire format | Complete | I2P and clearnet peers are filtered by address kind; compact I2P output is 32-byte hashes | +| R3 | Percent-encoded Base64 padding | Finding recorded | A disabled regression test is in `packages/http-protocol/src/v1/requests/announce.rs`; restore it while implementing URL decoding | +| R4 | I2P parse-error handling | Complete | Findings 3.9–3.10 / actions A3–A4 record lost parse context, unbounded input, and full-value echoing | +| R5 | I2P Destination validation and resource limits | Complete | Common-structures audit found missing certificate-type/payload validation and no pre-decode bound; A4 defines compatibility-aware requirements | +| R6 | REST API representation of I2P peers | Complete | Finding 3.11 / F5 defers final typed, separate I2P REST collections to REST API overhaul epic #144 and its ADR | +| R7 | Copilot review suggestions from original PR | Complete | Two additional valid blockers recorded as findings 3.12–3.13 / actions A5–A6; the compact parser suggestion duplicates A2 | +| R8 | I2P Destination spoofing threat model | Complete | Finding 3.14 / action A7 and `destination-spoofing-analysis.md` define the minimum secure policies before merge | +| R9 | Contributor response and fix verification | Pending | Re-run focused tests, manual evidence, and `linter all` after updates | + +--- + +## 7. Appendix: I2P BitTorrent Spec Summary + +Cross-referenced with the [official I2P BitTorrent specification](https://i2p.net/en/docs/applications/bittorrent/) +on 2026-08-17. + +| Aspect | Spec Requirement | PR #2050 Status | +| ------------------------ | -------------------------------------------------------------------- | --------------------------------------------------- | +| Addressing | Destination (387+ bytes, Base64 ~516+ chars), optional `.i2p` suffix | ✅ Implemented | +| Announce `ip` param | Full Base64 Destination (port is placeholder, often `6881`) | ✅ Implemented (port=1, not 6881) | +| Non-compact response | Full Destination string in `ip` field of peer dictionary | ✅ Implemented and isolated from clearnet responses | +| Compact response | 32-byte SHA-256 hash of binary Destination (I2P-only, no mixing) | ✅ Implemented and isolated from clearnet responses | +| Enforcement headers | `X-I2P-DestHash`, `X-I2P-DestB64`, `X-I2P-DestB32` (I2PTunnel-added) | ⚠️ Required before merge (A7) | +| Cross-network prevention | "Reject standard network announces... not deliver them in responses" | ✅ Implemented through swarm address-kind filtering | +| UDP announce | Separate UDP-over-I2P specification finalized 2025-06 | ✅ HTTP-only scope is correct | +| PEX | Extension message `i2p_pex` (32-byte hashes) | ❌ Not in scope | +| DHT | Extension message `i2p_dht`, 54-byte compact node info | ❌ Not in scope | +| SAMv3 | Recommended for non-Java clients; `SIGNATURE_TYPE=7` (Ed25519) | N/A (tracker-side only) | diff --git a/packages/axum-http-server/src/v1/extractors/announce_request.rs b/packages/axum-http-server/src/v1/extractors/announce_request.rs index b6072d29c..e21e23c76 100644 --- a/packages/axum-http-server/src/v1/extractors/announce_request.rs +++ b/packages/axum-http-server/src/v1/extractors/announce_request.rs @@ -88,7 +88,7 @@ mod tests { use std::str::FromStr; use torrust_info_hash::InfoHash; - use torrust_tracker_http_protocol::v1::requests::announce::{Announce, Compact, Event, NumberOfBytes}; + use torrust_tracker_http_protocol::v1::requests::announce::{Announce, AnnounceAddress, Compact, Event, NumberOfBytes}; use torrust_tracker_http_protocol::v1::responses::error::Error; use torrust_tracker_primitives::PeerId; @@ -113,7 +113,7 @@ mod tests { info_hash: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, - ip: Some(IpAddr::V4(Ipv4Addr::new(2, 137, 87, 41))), + ip: Some(AnnounceAddress::Ip(IpAddr::V4(Ipv4Addr::new(2, 137, 87, 41)))), downloaded: Some(NumberOfBytes::new(0)), uploaded: Some(NumberOfBytes::new(0)), left: Some(NumberOfBytes::new(0)), diff --git a/packages/axum-http-server/src/v1/handlers/announce.rs b/packages/axum-http-server/src/v1/handlers/announce.rs index 73e4868bd..949af67c4 100644 --- a/packages/axum-http-server/src/v1/handlers/announce.rs +++ b/packages/axum-http-server/src/v1/handlers/announce.rs @@ -13,7 +13,7 @@ use torrust_tracker_http_core::services::announce::{AnnounceService, HttpAnnounc use torrust_tracker_http_protocol::v1::requests::announce::{Announce, Compact}; use torrust_tracker_http_protocol::v1::responses::{self}; use torrust_tracker_http_protocol::v1::services::peer_ip_resolver::ClientIpSources; -use torrust_tracker_primitives::AnnounceData as DomainAnnounceData; +use torrust_tracker_primitives::{AnnounceData as DomainAnnounceData, PeerAddress as DomainPeerAddress}; use crate::v1::extractors::announce_request::ExtractRequest; use crate::v1::extractors::authentication_key::Extract as ExtractKey; @@ -106,9 +106,19 @@ fn to_protocol_announce_data(domain_data: DomainAnnounceData) -> responses::anno peers: domain_data .peers .into_iter() - .map(|peer| responses::announce::Peer { - peer_id: peer.peer_id, - peer_addr: peer.peer_addr, + .map(|peer| { + let peer_addr = match &peer.peer_addr { + DomainPeerAddress::Clearnet(address) => responses::announce::PeerAddress::Clearnet(*address), + DomainPeerAddress::I2p(address) => responses::announce::PeerAddress::I2p { + destination: address.destination.to_string(), + destination_hash: *address.destination.hash(), + }, + }; + + responses::announce::Peer { + peer_id: peer.peer_id, + peer_addr, + } }) .collect(), stats: responses::announce::SwarmMetadata { diff --git a/packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs b/packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs index bbd6c68c6..39b134acd 100644 --- a/packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs +++ b/packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs @@ -24,10 +24,10 @@ use torrust_tracker_client::http::client::Client; use torrust_tracker_http_protocol::percent_encoding::percent_encode_byte_array; use torrust_tracker_http_protocol::v1::requests::announce::{AnnounceBuilder, Compact}; use torrust_tracker_http_protocol::v1::responses::announce::deserialization::{ - CompactPeer, CompactPeerList, DeserializedNormal, DictionaryPeer, + CompactPeer, CompactPeerList, DeserializedCompact, DeserializedNormal, DictionaryPeer, }; -use torrust_tracker_primitives::PeerId as DomainPeerId; use torrust_tracker_primitives::peer::fixture::PeerBuilder; +use torrust_tracker_primitives::{I2pDestination, PeerId as DomainPeerId}; use torrust_tracker_test_helpers::{configuration, logging}; use crate::common::fixtures::invalid_info_hashes; @@ -105,7 +105,7 @@ async fn should_fail_when_url_query_parameters_are_invalid() { let http_tracker_config = Arc::new(cfg.http_trackers.unwrap()[0].clone()); let env = Started::new(&core_config, &http_tracker_config).await; - let invalid_query_param = "a=b=c"; + let invalid_query_param = "missing-value-separator"; let response = Client::new(env.base_url(), Duration::from_secs(5)) .unwrap() @@ -113,7 +113,7 @@ async fn should_fail_when_url_query_parameters_are_invalid() { .await .unwrap(); - assert_cannot_parse_query_param_error_response(response, "invalid param a=b=c").await; + assert_cannot_parse_query_param_error_response(response, "invalid param missing-value-separator").await; env.stop().await; } @@ -594,7 +594,7 @@ async fn should_return_the_list_of_previously_announced_peers() { min_interval: announce_policy.interval_min, peers: vec![DictionaryPeer { peer_id: previously_announced_peer.peer_id.as_bytes().to_vec(), - ip: previously_announced_peer.peer_addr.ip().to_string(), + ip: previously_announced_peer.peer_addr.ip().unwrap().to_string(), port: previously_announced_peer.peer_addr.port(), }], }, @@ -658,12 +658,12 @@ async fn should_return_the_list_of_previously_announced_peers_including_peers_us peers: vec![ DictionaryPeer { peer_id: peer_using_ipv4.peer_id.as_bytes().to_vec(), - ip: peer_using_ipv4.peer_addr.ip().to_string(), + ip: peer_using_ipv4.peer_addr.ip().unwrap().to_string(), port: peer_using_ipv4.peer_addr.port(), }, DictionaryPeer { peer_id: peer_using_ipv6.peer_id.as_bytes().to_vec(), - ip: peer_using_ipv6.peer_addr.ip().to_string(), + ip: peer_using_ipv6.peer_addr.ip().unwrap().to_string(), port: peer_using_ipv6.peer_addr.port(), }, ], @@ -689,14 +689,14 @@ async fn should_consider_two_peers_to_be_the_same_when_they_have_the_same_socket let announce_query_1 = AnnounceBuilder::default() .with_info_hash(&info_hash) .with_peer_id(&PeerId(peer.peer_id.0)) - .with_ip(peer.peer_addr.ip()) + .with_ip(peer.peer_addr.ip().unwrap()) .with_port(peer.peer_addr.port()) .query(); let announce_query_2 = AnnounceBuilder::default() .with_info_hash(&info_hash) .with_peer_id(&PeerId(*b"-qB00000000000000002")) // Different peer ID - .with_ip(peer.peer_addr.ip()) + .with_ip(peer.peer_addr.ip().unwrap()) .with_port(peer.peer_addr.port()) .query(); @@ -776,7 +776,7 @@ async fn should_return_the_compact_response() { incomplete: 0, interval: 120, min_interval: 120, - peers: CompactPeerList::new([CompactPeer::new(&previously_announced_peer.peer_addr)].to_vec()), + peers: CompactPeerList::new([CompactPeer::new(&previously_announced_peer.peer_addr.socket_addr().unwrap())].to_vec()), }; assert_compact_announce_response(response, &expected_response).await; @@ -784,6 +784,60 @@ async fn should_return_the_compact_response() { env.stop().await; } +#[tokio::test] +async fn it_should_return_i2p_destination_hashes_in_a_compact_response() { + logging::setup(); + + let cfg = configuration::ephemeral_public(); + let core_config = Arc::new(cfg.core.clone()); + let http_tracker_config = Arc::new(cfg.http_trackers.unwrap()[0].clone()); + let env = Started::new(&core_config, &http_tracker_config).await; + let client = Client::new(env.base_url(), Duration::from_secs(5)).unwrap(); + let info_hash = InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(); // DevSkim: ignore DS173237 + // cspell:disable-next-line + let first_destination = format!("{}BQAEAAAAAA==.i2p", "A".repeat(512)) + .parse::() + .unwrap(); + + client + .announce( + &AnnounceBuilder::default() + .with_info_hash(&info_hash) + .with_peer_id(&PeerId(*b"-qB00000000000000001")) + .with_port(1) + .with_i2p_destination(first_destination.clone()) + .with_compact(Compact::Accepted) + .query(), + ) + .await + .unwrap(); + + let response = client + .announce( + &AnnounceBuilder::default() + .with_info_hash(&info_hash) + .with_peer_id(&PeerId(*b"-qB00000000000000002")) + .with_port(1) + .with_i2p_destination( + // cspell:disable-next-line + format!("B{}BQAEAAAAAA==.i2p", "A".repeat(511)) + .parse::() + .unwrap(), + ) + .with_compact(Compact::Accepted) + .query(), + ) + .await + .unwrap(); + let bytes = response.bytes().await.unwrap(); + let announce = DeserializedCompact::from_bytes(&bytes).unwrap(); + + assert_eq!(announce.peers, *first_destination.hash()); + assert_eq!(announce.peers6, Vec::::new()); + + env.stop().await; +} + #[tokio::test] async fn should_return_the_compact_response_by_default() { logging::setup(); @@ -942,10 +996,10 @@ async fn should_assign_to_the_peer_ip_the_remote_client_ip_instead_of_the_peer_a .in_memory_torrent_repository .get_torrent_peers(&info_hash, usize::MAX) .await; - let peer_addr = peers[0].peer_addr; + let peer_addr = &peers[0].peer_addr; - assert_eq!(peer_addr.ip(), client_ip); - assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); + assert_eq!(peer_addr.ip(), Some(client_ip)); + assert_ne!(peer_addr.ip(), Some(IpAddr::from_str("2.2.2.2").unwrap())); env.stop().await; } @@ -986,7 +1040,7 @@ async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_to_the_peer_ip_t .in_memory_torrent_repository .get_torrent_peers(&info_hash, usize::MAX) .await; - let peer_addr = peers[0].peer_addr; + let peer_addr = &peers[0].peer_addr; let ext_ip: IpAddr = env .container @@ -996,8 +1050,8 @@ async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_to_the_peer_ip_t .external_ip .unwrap() .into(); - assert_eq!(peer_addr.ip(), ext_ip); - assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); + assert_eq!(peer_addr.ip(), Some(ext_ip)); + assert_ne!(peer_addr.ip(), Some(IpAddr::from_str("2.2.2.2").unwrap())); env.stop().await; } @@ -1039,7 +1093,7 @@ async fn when_the_client_ip_is_a_loopback_ipv6_it_should_assign_to_the_peer_ip_t .in_memory_torrent_repository .get_torrent_peers(&info_hash, usize::MAX) .await; - let peer_addr = peers[0].peer_addr; + let peer_addr = &peers[0].peer_addr; let ext_ip: IpAddr = env .container @@ -1049,8 +1103,8 @@ async fn when_the_client_ip_is_a_loopback_ipv6_it_should_assign_to_the_peer_ip_t .external_ip .unwrap() .into(); - assert_eq!(peer_addr.ip(), ext_ip); - assert_ne!(peer_addr.ip(), IpAddr::from_str("2.2.2.2").unwrap()); + assert_eq!(peer_addr.ip(), Some(ext_ip)); + assert_ne!(peer_addr.ip(), Some(IpAddr::from_str("2.2.2.2").unwrap())); env.stop().await; } @@ -1095,9 +1149,9 @@ async fn when_the_tracker_is_behind_a_reverse_proxy_it_should_assign_to_the_peer .in_memory_torrent_repository .get_torrent_peers(&info_hash, usize::MAX) .await; - let peer_addr = peers[0].peer_addr; + let peer_addr = &peers[0].peer_addr; - assert_eq!(peer_addr.ip(), IpAddr::from_str("150.172.238.178").unwrap()); + assert_eq!(peer_addr.ip(), Some(IpAddr::from_str("150.172.238.178").unwrap())); env.stop().await; } diff --git a/packages/axum-rest-api-server/tests/server/v1/contract/context/torrent.rs b/packages/axum-rest-api-server/tests/server/v1/contract/context/torrent.rs index e0265ddc0..817103c43 100644 --- a/packages/axum-rest-api-server/tests/server/v1/contract/context/torrent.rs +++ b/packages/axum-rest-api-server/tests/server/v1/contract/context/torrent.rs @@ -333,7 +333,7 @@ async fn should_allow_getting_a_torrent_info() { seeders: 1, completed: 0, leechers: 0, - peers: Some(vec![conversion::from_domain_peer(peer)]), + peers: Some(vec![conversion::from_domain_peer(&peer)]), }, ) .await; diff --git a/packages/http-core/benches/helpers/sync.rs b/packages/http-core/benches/helpers/sync.rs index d487bc54a..1fb83d292 100644 --- a/packages/http-core/benches/helpers/sync.rs +++ b/packages/http-core/benches/helpers/sync.rs @@ -12,7 +12,7 @@ pub async fn return_announce_data_once(samples: u64) -> Duration { let peer = sample_peer(); - let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(&peer); let announce_service = AnnounceService::new( core_tracker_services.core_config.clone(), diff --git a/packages/http-core/benches/helpers/util.rs b/packages/http-core/benches/helpers/util.rs index 7faf6f86e..364e56a2a 100644 --- a/packages/http-core/benches/helpers/util.rs +++ b/packages/http-core/benches/helpers/util.rs @@ -93,7 +93,7 @@ pub async fn initialize_core_tracker_services_with_config( pub fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -102,7 +102,7 @@ pub fn sample_peer() -> peer::Peer { } } -pub fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSources) { +pub fn sample_announce_request_for_peer(peer: &Peer) -> (Announce, ClientIpSources) { let announce_request = Announce { info_hash: sample_info_hash(), peer_id: peer.peer_id, @@ -123,7 +123,7 @@ pub fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSource let client_ip_sources = ClientIpSources { right_most_x_forwarded_for: None, - connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip(), 8080)), + connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip().unwrap(), 8080)), }; (announce_request, client_ip_sources) diff --git a/packages/http-core/src/lib.rs b/packages/http-core/src/lib.rs index fc6f5b068..13becb9aa 100644 --- a/packages/http-core/src/lib.rs +++ b/packages/http-core/src/lib.rs @@ -45,14 +45,15 @@ pub(crate) mod tests { peer.peer_addr = SocketAddr::new( IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), 8080, - ); + ) + .into(); peer } pub fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/http-core/src/services/announce.rs b/packages/http-core/src/services/announce.rs index 608943534..5a3826673 100644 --- a/packages/http-core/src/services/announce.rs +++ b/packages/http-core/src/services/announce.rs @@ -19,14 +19,14 @@ use torrust_tracker_core::authentication::{self, Key}; use torrust_tracker_core::error::{AnnounceError, TrackerCoreError, WhitelistError}; use torrust_tracker_core::whitelist; use torrust_tracker_http_protocol::v1::requests::announce::{ - Announce, Event as ProtocolAnnounceEvent, NumberOfBytes as ProtocolNumberOfBytes, + Announce, AnnounceAddress, Event as ProtocolAnnounceEvent, NumberOfBytes as ProtocolNumberOfBytes, }; use torrust_tracker_http_protocol::v1::responses::error::Error as HttpProtocolErrorResponse; use torrust_tracker_http_protocol::v1::services::peer_ip_resolver::{ ClientIpSources, PeerIpResolutionError, RemoteClientAddr, resolve_remote_client_addr, }; use torrust_tracker_primitives::peer::PeerAnnouncement; -use torrust_tracker_primitives::{AnnounceData, AnnounceEvent, NumberOfBytes}; +use torrust_tracker_primitives::{AnnounceData, AnnounceEvent, I2pPeerAddress, NumberOfBytes, PeerAddress}; use crate::event; use crate::event::Event; @@ -121,7 +121,12 @@ impl AnnounceService { PeerAnnouncement { peer_id: announce_request.peer_id, - peer_addr: std::net::SocketAddr::new(*peer_ip, announce_request.port), + peer_addr: match &announce_request.ip { + Some(AnnounceAddress::I2p(destination)) => PeerAddress::I2p(I2pPeerAddress { + destination: destination.clone(), + }), + _ => std::net::SocketAddr::new(*peer_ip, announce_request.port).into(), + }, updated: ::now(), uploaded: NumberOfBytes::new(uploaded.0), downloaded: NumberOfBytes::new(downloaded.0), @@ -323,7 +328,7 @@ mod tests { ) } - fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSources) { + fn sample_announce_request_for_peer(peer: &Peer) -> (Announce, ClientIpSources) { let announce_request = Announce { info_hash: sample_info_hash(), peer_id: peer.peer_id, @@ -358,7 +363,7 @@ mod tests { let client_ip_sources = ClientIpSources { right_most_x_forwarded_for: None, - connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip(), 8080)), + connection_info_socket_address: Some(SocketAddr::new(peer.peer_addr.ip().unwrap(), 8080)), }; (announce_request, client_ip_sources) @@ -392,9 +397,10 @@ mod tests { use mockall::predicate::{self}; use torrust_net_primitives::service_binding::{Protocol, ServiceBinding}; use torrust_tracker_configuration::Configuration; + use torrust_tracker_http_protocol::v1::requests::announce::AnnounceAddress; use torrust_tracker_http_protocol::v1::services::peer_ip_resolver::{RemoteClientAddr, ResolvedIp}; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; - use torrust_tracker_primitives::{AnnounceData, peer}; + use torrust_tracker_primitives::{AnnounceData, I2pDestination, PeerAddress, PeerId, peer}; use torrust_tracker_test_helpers::configuration; use crate::event::test::announce_events_match; @@ -412,7 +418,7 @@ mod tests { let peer = sample_peer(); - let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(&peer); let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); @@ -443,11 +449,50 @@ mod tests { assert_eq!(announce_data, expected_announce_data); } + #[tokio::test] + async fn it_should_coordinate_i2p_peers_by_their_destinations() { + let (core_tracker_services, core_http_tracker_services) = initialize_core_tracker_services().await; + let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); + let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); + let announce_service = AnnounceService::new( + core_tracker_services.core_config, + core_tracker_services.announce_handler, + core_tracker_services.authentication_service, + core_tracker_services.whitelist_authorization, + core_http_tracker_services.http_stats_event_sender, + ); + + let (mut first_request, client_ip_sources) = sample_announce_request_for_peer(&sample_peer()); + first_request.ip = Some(AnnounceAddress::I2p( + format!("{}.i2p", "A".repeat(516)).parse::().unwrap(), + )); + announce_service + .handle_announce(&first_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + + let mut second_peer = sample_peer(); + second_peer.peer_id = PeerId(*b"-qB00000000000000002"); + let (mut second_request, _) = sample_announce_request_for_peer(&second_peer); + second_request.ip = Some(AnnounceAddress::I2p( + format!("B{}.i2p", "A".repeat(515)).parse::().unwrap(), + )); + + let announce_data = announce_service + .handle_announce(&second_request, &client_ip_sources, &server_service_binding, None) + .await + .unwrap(); + + assert_eq!(announce_data.peers.len(), 1); + assert!(matches!(announce_data.peers[0].peer_addr, PeerAddress::I2p(_))); + } + #[tokio::test] async fn it_should_send_the_tcp_4_announce_event_when_the_peer_uses_ipv4() { let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); let peer = sample_peer_using_ipv4(); + let expected_peer = peer.clone(); let remote_client_ip = IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)); let server_service_binding_clone = server_service_binding.clone(); @@ -456,8 +501,8 @@ mod tests { http_stats_event_sender_mock .expect_send() .with(predicate::function(move |event| { - let mut announcement = peer; - announcement.peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080); + let mut announcement = expected_peer.clone(); + announcement.peer_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(); let expected_event = Event::TcpAnnounce { connection: ConnectionContext::new( @@ -478,7 +523,7 @@ mod tests { core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; - let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(&peer); let announce_service = AnnounceService::new( core_tracker_services.core_config.clone(), @@ -507,7 +552,7 @@ mod tests { fn peer_with_the_ipv4_loopback_ip() -> peer::Peer { let loopback_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); let mut peer = sample_peer(); - peer.peer_addr = SocketAddr::new(loopback_ip, 8080); + peer.peer_addr = SocketAddr::new(loopback_ip, 8080).into(); peer } @@ -519,6 +564,7 @@ mod tests { let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); let peer = peer_with_the_ipv4_loopback_ip(); + let expected_peer = peer.clone(); let remote_client_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); let server_service_binding_clone = server_service_binding.clone(); @@ -527,11 +573,12 @@ mod tests { http_stats_event_sender_mock .expect_send() .with(predicate::function(move |event| { - let mut peer_announcement = peer; + let mut peer_announcement = expected_peer.clone(); peer_announcement.peer_addr = SocketAddr::new( IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), 8080, - ); + ) + .into(); let expected_event = Event::TcpAnnounce { connection: ConnectionContext::new( @@ -554,7 +601,7 @@ mod tests { core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; - let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(&peer); let announce_service = AnnounceService::new( core_tracker_services.core_config.clone(), @@ -576,6 +623,7 @@ mod tests { let server_socket_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 7070); let server_service_binding = ServiceBinding::new(Protocol::HTTP, server_socket_addr).unwrap(); let peer = sample_peer_using_ipv6(); + let expected_peer = peer.clone(); let remote_client_ip = IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)); let mut http_stats_event_sender_mock = MockHttpStatsEventSender::new(); @@ -588,7 +636,7 @@ mod tests { server_service_binding.clone(), ), info_hash: sample_info_hash(), - announcement: peer, + announcement: expected_peer.clone(), }; announce_events_match(event, &expected_event) })) @@ -599,7 +647,7 @@ mod tests { let (core_tracker_services, mut core_http_tracker_services) = initialize_core_tracker_services().await; core_http_tracker_services.http_stats_event_sender = http_stats_event_sender; - let (announce_request, client_ip_sources) = sample_announce_request_for_peer(peer); + let (announce_request, client_ip_sources) = sample_announce_request_for_peer(&peer); let announce_service = AnnounceService::new( core_tracker_services.core_config.clone(), diff --git a/packages/http-core/src/services/scrape.rs b/packages/http-core/src/services/scrape.rs index fa5b7dfe9..8a4d42f36 100644 --- a/packages/http-core/src/services/scrape.rs +++ b/packages/http-core/src/services/scrape.rs @@ -238,7 +238,7 @@ mod tests { fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -299,7 +299,7 @@ mod tests { // Announce a new peer to force scrape data to contain non zeroed data let mut peer = sample_peer(); - let original_peer_ip = peer.ip(); + let original_peer_ip = peer.ip().unwrap(); container .announce_handler .handle_announcement(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::AsManyAsPossible) @@ -489,7 +489,7 @@ mod tests { // Announce a new peer to force scrape data to contain non zeroed data let mut peer = sample_peer(); - let original_peer_ip = peer.ip(); + let original_peer_ip = peer.ip().unwrap(); container .announce_handler .handle_announcement(&info_hash, &mut peer, &original_peer_ip, &PeersWanted::AsManyAsPossible) diff --git a/packages/http-protocol/Cargo.toml b/packages/http-protocol/Cargo.toml index ebaabcfa7..0d7377982 100644 --- a/packages/http-protocol/Cargo.toml +++ b/packages/http-protocol/Cargo.toml @@ -28,6 +28,7 @@ thiserror = "2" torrust-clock = "3.0.0" torrust-bencode = "3.0.0" torrust-located-error = "3.0.0" +torrust-tracker-primitives = { version = "3.0.0", path = "../primitives" } [package.metadata.cargo-machete] ignored = [ "serde_bytes" ] diff --git a/packages/http-protocol/src/v1/query.rs b/packages/http-protocol/src/v1/query.rs index 878033423..9aa29635b 100644 --- a/packages/http-protocol/src/v1/query.rs +++ b/packages/http-protocol/src/v1/query.rs @@ -97,8 +97,7 @@ impl Query { /// from a string. #[derive(Error, Debug)] pub enum ParseQueryError { - /// Invalid URL query param. For example: `"name=value=value"`. It contains - /// an unescaped `=` character. + /// Invalid URL query parameter without a name/value separator. #[error("invalid param {raw_param} in {location}")] InvalidParam { location: &'static Location<'static>, @@ -168,18 +167,14 @@ impl FromStr for NameValuePair { type Err = ParseQueryError; fn from_str(raw_param: &str) -> Result { - let pair = raw_param.split('=').collect::>(); - - if pair.len() != 2 { - return Err(ParseQueryError::InvalidParam { - location: Location::caller(), - raw_param: raw_param.to_owned(), - }); - } + let (name, value) = raw_param.split_once('=').ok_or_else(|| ParseQueryError::InvalidParam { + location: Location::caller(), + raw_param: raw_param.to_owned(), + })?; Ok(Self { - name: pair[0].to_owned(), - value: pair[1].to_owned(), + name: name.to_owned(), + value: value.to_owned(), }) } } @@ -257,12 +252,21 @@ mod tests { } #[test] - fn should_fail_parsing_an_invalid_query_string() { - let invalid_raw_query = "name=value=value"; + fn it_should_preserve_equals_characters_in_a_query_parameter_value() { + let raw_query = "name=value=="; + + let query = raw_query.parse::().unwrap(); + + assert_eq!(query.get_param("name"), Some("value==".to_string())); + } + + #[test] + fn it_should_reject_a_query_parameter_without_a_separator() { + let invalid_raw_query = "name"; - let query = invalid_raw_query.parse::(); + let result = invalid_raw_query.parse::(); - assert!(query.is_err()); + assert!(result.is_err()); } #[test] @@ -345,12 +349,21 @@ mod tests { } #[test] - fn should_fail_parsing_an_invalid_query_param() { - let invalid_raw_param = "name=value=value"; + fn it_should_preserve_equals_characters_in_the_value() { + let raw_param = "name=value=="; + + let param = raw_param.parse::().unwrap(); + + assert_eq!(param.value, "value=="); + } + + #[test] + fn it_should_reject_a_param_without_a_separator() { + let invalid_raw_param = "name"; - let query = invalid_raw_param.parse::(); + let result = invalid_raw_param.parse::(); - assert!(query.is_err()); + assert!(result.is_err()); } #[test] diff --git a/packages/http-protocol/src/v1/requests/announce.rs b/packages/http-protocol/src/v1/requests/announce.rs index 4f91e1ca1..7fba34af6 100644 --- a/packages/http-protocol/src/v1/requests/announce.rs +++ b/packages/http-protocol/src/v1/requests/announce.rs @@ -12,6 +12,7 @@ use thiserror::Error; use torrust_info_hash::InfoHash; use torrust_located_error::{Located, LocatedError}; use torrust_peer_id::PeerId; +use torrust_tracker_primitives::I2pDestination; use crate::percent_encoding::{ PeerIdConversionError, percent_decode_info_hash, percent_decode_peer_id, percent_encode_byte_array, @@ -96,8 +97,8 @@ pub struct Announce { pub port: u16, // Optional params - /// The peer IP address (BEP 3 `ip` parameter). - pub ip: Option, + /// The peer IP address or I2P Destination (BEP 3 `ip` parameter). + pub ip: Option, /// The number of bytes downloaded by the peer. pub downloaded: Option, @@ -120,6 +121,22 @@ pub struct Announce { pub numwant: Option, } +/// Address supplied in the BEP 3 `ip` parameter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AnnounceAddress { + Ip(IpAddr), + I2p(I2pDestination), +} + +impl fmt::Display for AnnounceAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Ip(address) => address.fmt(f), + Self::I2p(destination) => destination.fmt(f), + } + } +} + /// Errors that can occur when parsing the `Announce` request. /// /// The `info_hash` and `peer_id` query params are special because they contain @@ -291,7 +308,7 @@ impl TryFrom for Announce { event: extract_event(&query)?, compact: extract_compact(&query)?, numwant: extract_numwant(&query)?, - ip: extract_ip(&query), + ip: extract_ip(&query)?, }) } } @@ -376,7 +393,7 @@ impl AnnounceBuilder { info_hash: InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, - ip: Some(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 88))), + ip: Some(AnnounceAddress::Ip(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 88)))), downloaded: None, uploaded: None, left: None, @@ -409,7 +426,13 @@ impl AnnounceBuilder { #[must_use] pub fn with_ip(mut self, ip: IpAddr) -> Self { - self.announce.ip = Some(ip); + self.announce.ip = Some(AnnounceAddress::Ip(ip)); + self + } + + #[must_use] + pub fn with_i2p_destination(mut self, destination: I2pDestination) -> Self { + self.announce.ip = Some(AnnounceAddress::I2p(destination)); self } @@ -563,10 +586,32 @@ fn extract_number_of_bytes_from_param(param_name: &str, query: &Query) -> Result } } -fn extract_ip(query: &Query) -> Option { +fn extract_ip(query: &Query) -> Result, ParseAnnounceQueryError> { match query.get_param(IP) { - Some(raw_param) => IpAddr::from_str(&raw_param).ok(), - None => None, + Some(raw_param) => { + if let Ok(ip) = IpAddr::from_str(&raw_param) { + return Ok(Some(AnnounceAddress::Ip(ip))); + } + + let has_i2p_suffix = raw_param + .rsplit_once('.') + .is_some_and(|(_, suffix)| suffix.eq_ignore_ascii_case("i2p")); + + match I2pDestination::from_str(&raw_param) { + Ok(destination) => return Ok(Some(AnnounceAddress::I2p(destination))), + Err(_) if has_i2p_suffix => { + return Err(ParseAnnounceQueryError::InvalidParam { + param_name: IP.to_owned(), + param_value: raw_param, + location: Location::caller(), + }); + } + Err(_) => {} + } + + Ok(None) + } + None => Ok(None), } } @@ -608,8 +653,8 @@ mod tests { use crate::v1::query::Query; use crate::v1::requests::announce::{ - Announce, COMPACT, Compact, DOWNLOADED, EVENT, Event, INFO_HASH, LEFT, NUMWANT, NumberOfBytes, PEER_ID, PORT, - UPLOADED, + Announce, AnnounceAddress, COMPACT, Compact, DOWNLOADED, EVENT, Event, INFO_HASH, IP, LEFT, NUMWANT, NumberOfBytes, + PEER_ID, PORT, UPLOADED, }; #[test] @@ -678,6 +723,76 @@ mod tests { ); } + #[test] + fn it_should_parse_a_padded_i2p_destination_from_the_ip_param() { + // 391 decoded bytes: 384 key bytes, a key certificate with its + // four-byte key-type payload, and `==` Base64 padding. + // cspell:disable-next-line + let destination = format!("{}BQAEAAAAAA==.i2p", "A".repeat(512)); + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-RC3000-000000000001"), + (PORT, "1"), + (IP, &destination), + ]) + .to_string(); + + let announce_request = Announce::try_from(raw_query.parse::().unwrap()).unwrap(); + + assert!(matches!(announce_request.ip, Some(AnnounceAddress::I2p(_)))); + } + + /* + #[test] + fn it_should_parse_a_percent_encoded_padded_i2p_destination_from_the_ip_param() { + // Keep this regression test disabled until Query percent-decodes + // parameter values. The current implementation passes `%3D%3D` + // literally to I2pDestination and rejects an otherwise valid + // Base64-padded Destination. + // cspell:disable-next-line + let destination = format!("{}BQAEAAAAAA%3D%3D.i2p", "A".repeat(512)); + let raw_query = format!( + "{INFO_HASH}=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&{PEER_ID}=-RC3000-000000000001&{PORT}=1&{IP}={destination}" + ); + + let announce_request = Announce::try_from(raw_query.parse::().unwrap()).unwrap(); + + assert!(matches!(announce_request.ip, Some(AnnounceAddress::I2p(_)))); + } + */ + + #[test] + fn it_should_parse_an_i2p_destination_without_the_i2p_suffix() { + let destination = "A".repeat(516); + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-RC3000-000000000001"), + (PORT, "1"), + (IP, &destination), + ]) + .to_string(); + + let announce_request = Announce::try_from(raw_query.parse::().unwrap()).unwrap(); + + assert!(matches!(announce_request.ip, Some(AnnounceAddress::I2p(_)))); + } + + #[test] + fn it_should_reject_an_invalid_i2p_destination() { + let destination = "invalid.i2p"; + let raw_query = Query::from(vec![ + (INFO_HASH, "%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0"), + (PEER_ID, "-RC3000-000000000001"), + (PORT, "1"), + (IP, destination), + ]) + .to_string(); + + let result = Announce::try_from(raw_query.parse::().unwrap()); + + assert!(result.is_err()); + } + mod when_it_is_instantiated_from_the_url_query_params { use crate::v1::query::Query; diff --git a/packages/http-protocol/src/v1/responses/announce/data.rs b/packages/http-protocol/src/v1/responses/announce/data.rs index 06da05ac3..7ea08d2a2 100644 --- a/packages/http-protocol/src/v1/responses/announce/data.rs +++ b/packages/http-protocol/src/v1/responses/announce/data.rs @@ -59,8 +59,17 @@ impl SwarmMetadata { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Peer { pub peer_id: PeerId, - pub peer_addr: SocketAddr, + pub peer_addr: PeerAddress, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PeerAddress { + Clearnet(SocketAddr), + I2p { + destination: String, + destination_hash: [u8; 32], + }, } diff --git a/packages/http-protocol/src/v1/responses/announce/encoding.rs b/packages/http-protocol/src/v1/responses/announce/encoding.rs index a70b9f4b8..14d5e61b7 100644 --- a/packages/http-protocol/src/v1/responses/announce/encoding.rs +++ b/packages/http-protocol/src/v1/responses/announce/encoding.rs @@ -2,13 +2,14 @@ //! //! Types for encoding announce responses into bencoded bytes. //! Supports two encoding forms: [`Normal`] (dictionary-based) and [`Compact`] (packed binary). -use std::io::Write; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -use derive_more::{AsRef, Constructor, From}; +use derive_more::{AsRef, Constructor}; use torrust_bencode::{BMutAccess, BencodeMut, ben_bytes, ben_int, ben_list, ben_map}; -use crate::v1::responses::announce::data::{AnnounceData, Peer}; +use crate::v1::responses::announce::data::{AnnounceData, Peer, PeerAddress}; + +const I2P_PLACEHOLDER_PORT: u16 = 1; /// An [`Announce`] response, that can be anything that is convertible from [`AnnounceData`]. /// @@ -26,6 +27,8 @@ use crate::v1::responses::announce::data::{AnnounceData, Peer}; /// - [BEP 03: The `BitTorrent` Protocol Specification](https://www.bittorrent.org/beps/bep_0003.html) /// - [BEP 23: Tracker Returns Compact Peer Lists](https://www.bittorrent.org/beps/bep_0023.html) /// - [BEP 07: IPv6 Tracker Extension](https://www.bittorrent.org/beps/bep_0007.html) +/// - [I2P BitTorrent client protocol](https://i2p.net/en/docs/applications/bittorrent/) +/// // `derive_more::Constructor` generates `field: field` initializers on this MSRV-compatible version. // Nightly Clippy diagnoses that proc-macro expansion; remove this allowance once derive_more emits // field-init shorthand. @@ -98,21 +101,30 @@ pub struct Compact { impl From for Compact { fn from(data: AnnounceData) -> Self { - let compact_peers: Vec = data.peers.into_iter().map(CompactPeer::from).collect(); - - let (peers, peers6): (Vec>, Vec>) = - compact_peers.into_iter().collect(); + let mut peers = vec![]; + let mut peers6 = vec![]; - let peers_encoded: CompactPeersEncoded = peers.into_iter().collect(); - let peers_encoded_6: CompactPeersEncoded = peers6.into_iter().collect(); + for peer in data.peers.into_iter().map(CompactPeer::from) { + match peer { + CompactPeer::V4(peer) => { + peers.extend(u32::from(peer.ip).to_be_bytes()); + peers.extend(peer.port.to_be_bytes()); + } + CompactPeer::V6(peer) => { + peers6.extend(u128::from(peer.ip).to_be_bytes()); + peers6.extend(peer.port.to_be_bytes()); + } + CompactPeer::I2p(hash) => peers.extend(hash), + } + } Self { complete: data.stats.complete.into(), incomplete: data.stats.incomplete.into(), interval: data.policy.interval.into(), min_interval: data.policy.interval_min.into(), - peers: peers_encoded.0, - peers6: peers_encoded_6.0, + peers, + peers6, } } } @@ -135,13 +147,12 @@ impl Into> for Compact { /// A [`NormalPeer`], for the [`Normal`] form. /// /// ```rust -/// use std::net::{IpAddr, Ipv4Addr}; /// use torrust_tracker_http_protocol::v1::responses::announce::{Normal, NormalPeer}; /// /// let peer = NormalPeer { /// peer_id: *b"-RC3000-000000000001", -/// ip: IpAddr::V4(Ipv4Addr::new(0x69, 0x69, 0x69, 0x69)), // 105.105.105.105 -/// port: 0x7070, // 28784 +/// ip: "105.105.105.105".to_owned(), +/// port: 0x7070, // 28784 /// }; /// /// ``` @@ -149,18 +160,25 @@ impl Into> for Compact { pub struct NormalPeer { /// The peer's ID. pub peer_id: [u8; 20], - /// The peer's IP address. - pub ip: IpAddr, + /// The peer's IP address or I2P Destination. + pub ip: String, /// The peer's port number. pub port: u16, } impl From for NormalPeer { fn from(peer: Peer) -> Self { - NormalPeer { - peer_id: peer.peer_id.0, - ip: peer.peer_addr.ip(), - port: peer.peer_addr.port(), + match peer.peer_addr { + PeerAddress::Clearnet(address) => NormalPeer { + peer_id: peer.peer_id.0, + ip: address.ip().to_string(), + port: address.port(), + }, + PeerAddress::I2p { destination, .. } => NormalPeer { + peer_id: peer.peer_id.0, + ip: destination, + port: I2P_PLACEHOLDER_PORT, + }, } } } @@ -169,7 +187,7 @@ impl From<&NormalPeer> for BencodeMut<'_> { fn from(value: &NormalPeer) -> Self { ben_map! { "peer id" => ben_bytes!(value.peer_id.clone().to_vec()), - "ip" => ben_bytes!(value.ip.to_string()), + "ip" => ben_bytes!(value.ip.clone()), "port" => ben_int!(i64::from(value.port)) } } @@ -203,6 +221,8 @@ pub enum CompactPeer { V4(CompactPeerData), /// The peer's port number. V6(CompactPeerData), + /// The SHA-256 hash of an I2P Destination. + I2p([u8; 32]), } impl CompactPeer { @@ -249,9 +269,16 @@ impl CompactPeer { impl From for CompactPeer { fn from(peer: Peer) -> Self { - match (peer.peer_addr.ip(), peer.peer_addr.port()) { - (IpAddr::V4(ip), port) => Self::V4(CompactPeerData { ip, port }), - (IpAddr::V6(ip), port) => Self::V6(CompactPeerData { ip, port }), + match peer.peer_addr { + PeerAddress::Clearnet(SocketAddr::V4(address)) => Self::V4(CompactPeerData { + ip: *address.ip(), + port: address.port(), + }), + PeerAddress::Clearnet(SocketAddr::V6(address)) => Self::V6(CompactPeerData { + ip: *address.ip(), + port: address.port(), + }), + PeerAddress::I2p { destination_hash, .. } => Self::I2p(destination_hash), } } } @@ -266,54 +293,6 @@ pub struct CompactPeerData { pub port: u16, } -impl FromIterator for (Vec>, Vec>) { - fn from_iter>(iter: T) -> Self { - let mut peers_v4: Vec> = vec![]; - let mut peers_v6: Vec> = vec![]; - - for peer in iter { - match peer { - CompactPeer::V4(peer) => peers_v4.push(peer), - CompactPeer::V6(peer6) => peers_v6.push(peer6), - } - } - - (peers_v4, peers_v6) - } -} - -#[derive(From, PartialEq)] -struct CompactPeersEncoded(Vec); - -impl FromIterator> for CompactPeersEncoded { - fn from_iter>>(iter: T) -> Self { - let mut bytes: Vec = vec![]; - - for peer in iter { - bytes - .write_all(&u32::from(peer.ip).to_be_bytes()) - .expect("it should write peer ip"); - bytes.write_all(&peer.port.to_be_bytes()).expect("it should write peer port"); - } - - bytes.into() - } -} - -impl FromIterator> for CompactPeersEncoded { - fn from_iter>>(iter: T) -> Self { - let mut bytes: Vec = Vec::new(); - - for peer in iter { - bytes - .write_all(&u128::from(peer.ip).to_be_bytes()) - .expect("it should write peer ip"); - bytes.write_all(&peer.port.to_be_bytes()).expect("it should write peer port"); - } - bytes.into() - } -} - #[cfg(test)] mod tests { @@ -321,7 +300,9 @@ mod tests { use torrust_peer_id::PeerId; - use crate::v1::responses::announce::{Announce, AnnounceData, AnnouncePolicy, Compact, Normal, Peer, SwarmMetadata}; + use crate::v1::responses::announce::{ + Announce, AnnounceData, AnnouncePolicy, Compact, Normal, Peer, PeerAddress, SwarmMetadata, + }; // Some ascii values used in tests: // @@ -340,15 +321,15 @@ mod tests { let peer_ipv4 = Peer { peer_id: PeerId(*b"-RC3000-000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0x69, 0x69, 0x69, 0x69)), 0x7070), + peer_addr: PeerAddress::Clearnet(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0x69, 0x69, 0x69, 0x69)), 0x7070)), }; let peer_ipv6 = Peer { peer_id: PeerId(*b"-RC3000-000000000002"), - peer_addr: SocketAddr::new( + peer_addr: PeerAddress::Clearnet(SocketAddr::new( IpAddr::V6(Ipv6Addr::new(0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969, 0x6969)), 0x7070, - ), + )), }; let peers = vec![peer_ipv4, peer_ipv6]; @@ -385,4 +366,50 @@ mod tests { String::from_utf8(expected_bytes.to_vec()).unwrap() ); } + + #[test] + fn it_should_encode_an_i2p_peer_as_a_destination_in_a_non_compact_response() { + let destination = format!("{}.i2p", "A".repeat(516)); + let data = AnnounceData::new( + vec![Peer { + peer_id: PeerId(*b"-RC3000-000000000001"), + peer_addr: PeerAddress::I2p { + destination: destination.clone(), + destination_hash: [7; 32], + }, + }], + SwarmMetadata::default(), + AnnouncePolicy::default(), + ); + + let response: Announce = data.into(); + let bytes: Vec = response.data.into(); + let decoded = serde_bencode::from_bytes::(&bytes).unwrap(); + + assert_eq!(decoded.peers[0].ip, destination); + assert_eq!(decoded.peers[0].port, 1); + } + + #[test] + fn it_should_encode_an_i2p_peer_hash_in_a_compact_response() { + let destination_hash = [7; 32]; + let data = AnnounceData::new( + vec![Peer { + peer_id: PeerId(*b"-RC3000-000000000001"), + peer_addr: PeerAddress::I2p { + destination: format!("{}.i2p", "A".repeat(516)), + destination_hash, + }, + }], + SwarmMetadata::default(), + AnnouncePolicy::default(), + ); + + let response: Announce = data.into(); + let bytes: Vec = response.data.into(); + let decoded = crate::v1::responses::announce::DeserializedCompact::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.peers, destination_hash); + assert_eq!(decoded.peers6, []); + } } diff --git a/packages/http-protocol/src/v1/responses/announce/mod.rs b/packages/http-protocol/src/v1/responses/announce/mod.rs index 57d746382..eb5b27d8a 100644 --- a/packages/http-protocol/src/v1/responses/announce/mod.rs +++ b/packages/http-protocol/src/v1/responses/announce/mod.rs @@ -3,6 +3,6 @@ pub mod data; pub mod deserialization; pub mod encoding; -pub use data::{AnnounceData, AnnouncePolicy, Peer, SwarmMetadata}; +pub use data::{AnnounceData, AnnouncePolicy, Peer, PeerAddress, SwarmMetadata}; pub use deserialization::{CompactPeerList, DeserializedCompact, DeserializedCompactParsed, DeserializedNormal, DictionaryPeer}; pub use encoding::{Announce, Compact, CompactPeer, CompactPeerData, Normal, NormalPeer}; diff --git a/packages/primitives/Cargo.toml b/packages/primitives/Cargo.toml index e2f35549b..f03029c27 100644 --- a/packages/primitives/Cargo.toml +++ b/packages/primitives/Cargo.toml @@ -16,10 +16,12 @@ version = "3.0.0" [dependencies] torrust-peer-id = "0.1.0" +base64 = "0.22.1" binascii = "0" torrust-info-hash = "=0.2.0" derive_more = { version = "2", features = [ "constructor", "display" ] } serde = { version = "1", features = [ "derive" ] } +sha2 = "0.11.0" tdyne-peer-id = "1" tdyne-peer-id-registry = "0" thiserror = "2" diff --git a/packages/primitives/src/i2p.rs b/packages/primitives/src/i2p.rs new file mode 100644 index 000000000..1fd6e68e1 --- /dev/null +++ b/packages/primitives/src/i2p.rs @@ -0,0 +1,158 @@ +//! I2P addressing primitives. + +use std::fmt; +use std::str::FromStr; + +use base64::Engine; +use base64::alphabet::Alphabet; +use base64::engine::{GeneralPurpose, GeneralPurposeConfig}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +const I2P_BASE64_ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~"; +const I2P_SUFFIX: &str = "i2p"; +const MIN_I2P_DESTINATION_BYTES: usize = 387; +const I2P_CERTIFICATE_LENGTH_OFFSET: usize = 385; + +/// A validated I2P Base64 Destination. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct I2pDestination { + value: Box, + hash: [u8; 32], +} + +impl I2pDestination { + /// Returns the SHA-256 hash of the decoded binary Destination. + #[must_use] + pub const fn hash(&self) -> &[u8; 32] { + &self.hash + } +} + +impl fmt::Display for I2pDestination { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.value) + } +} + +impl FromStr for I2pDestination { + type Err = ParseI2pDestinationError; + + fn from_str(value: &str) -> Result { + let encoded = value + .rsplit_once('.') + .filter(|(_, suffix)| suffix.eq_ignore_ascii_case(I2P_SUFFIX)) + .map_or(value, |(encoded, _)| encoded); + let alphabet = + Alphabet::new(I2P_BASE64_ALPHABET).expect("the I2P Base64 alphabet must contain 64 unique ASCII characters"); + let engine = GeneralPurpose::new(&alphabet, GeneralPurposeConfig::new()); + let decoded = engine.decode(encoded).map_err(|_| ParseI2pDestinationError::InvalidBase64)?; + + if decoded.len() < MIN_I2P_DESTINATION_BYTES { + return Err(ParseI2pDestinationError::TooShort { actual: decoded.len() }); + } + + let certificate_payload_length = usize::from(u16::from_be_bytes([ + decoded[I2P_CERTIFICATE_LENGTH_OFFSET], + decoded[I2P_CERTIFICATE_LENGTH_OFFSET + 1], + ])); + let expected_length = MIN_I2P_DESTINATION_BYTES + certificate_payload_length; + + if decoded.len() != expected_length { + return Err(ParseI2pDestinationError::InvalidCertificateLength { + declared: certificate_payload_length, + actual: decoded.len() - MIN_I2P_DESTINATION_BYTES, + }); + } + + Ok(Self { + value: format!("{encoded}.{I2P_SUFFIX}").into_boxed_str(), + hash: Sha256::digest(decoded).into(), + }) + } +} + +/// Error returned when parsing an I2P Destination. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseI2pDestinationError { + #[error("the I2P Destination is not valid I2P Base64")] + InvalidBase64, + #[error("the decoded I2P Destination must contain at least {MIN_I2P_DESTINATION_BYTES} bytes, got {actual}")] + TooShort { actual: usize }, + #[error("the I2P certificate declares a {declared}-byte payload, but the Destination contains {actual} payload bytes")] + InvalidCertificateLength { declared: usize, actual: usize }, +} + +/// An I2P peer address. I2P routes by Destination and has no peer port. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct I2pPeerAddress { + /// The peer's full I2P Destination. + pub destination: I2pDestination, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_parse_and_normalize_a_valid_i2p_base64_destination() { + let destination = "A".repeat(516); + let destination_with_uppercase_suffix = format!("{destination}.I2P"); + + let parsed = I2pDestination::from_str(&destination_with_uppercase_suffix).unwrap(); + + assert_eq!(parsed.to_string(), format!("{destination}.i2p")); + assert_eq!( + parsed.hash(), + &[ + 0x31, 0x19, 0xfc, 0xeb, 0x0e, 0xad, 0x1d, 0x08, 0x04, 0xdb, 0x90, 0xfb, 0x0c, 0x87, 0xa3, 0x38, 0x10, 0x89, 0xf9, + 0xd2, 0x26, 0x4a, 0x37, 0x6c, 0x41, 0xa3, 0x9a, 0x06, 0xe5, 0x32, 0xa6, 0x41, + ] + ); + } + + #[test] + fn it_should_reject_an_i2p_destination_with_invalid_base64() { + let destination = format!("{}.i2p", "!".repeat(516)); + + let error = I2pDestination::from_str(&destination).unwrap_err(); + + assert_eq!(error, ParseI2pDestinationError::InvalidBase64); + } + + #[test] + fn it_should_reject_an_i2p_destination_shorter_than_the_minimum_length() { + let destination = "A".repeat(512); + + let error = I2pDestination::from_str(&destination).unwrap_err(); + + assert_eq!(error, ParseI2pDestinationError::TooShort { actual: 384 }); + } + + #[test] + fn it_should_parse_a_long_padded_destination_when_the_certificate_length_matches() { + let certificate_payload_length = 91_u16; + let mut decoded = vec![0; 387 + usize::from(certificate_payload_length)]; + decoded[384] = 5; + decoded[385..387].copy_from_slice(&certificate_payload_length.to_be_bytes()); + let alphabet = Alphabet::new(I2P_BASE64_ALPHABET).unwrap(); + let encoded = GeneralPurpose::new(&alphabet, GeneralPurposeConfig::new()).encode(decoded); + + let parsed = I2pDestination::from_str(&encoded).unwrap(); + + assert!(encoded.ends_with("==")); + assert_eq!(parsed.to_string(), format!("{encoded}.i2p")); + } + + #[test] + fn it_should_reject_a_destination_when_the_certificate_length_does_not_match() { + let destination = "A".repeat(520); + + let error = I2pDestination::from_str(&destination).unwrap_err(); + + assert_eq!( + error, + ParseI2pDestinationError::InvalidCertificateLength { declared: 0, actual: 3 } + ); + } +} diff --git a/packages/primitives/src/lib.rs b/packages/primitives/src/lib.rs index 51f183721..a18d9012a 100644 --- a/packages/primitives/src/lib.rs +++ b/packages/primitives/src/lib.rs @@ -7,6 +7,7 @@ pub mod announce; pub mod configuration_instance_id; pub mod driver; +pub mod i2p; pub mod mode; pub mod number_of_bytes; pub mod pagination; @@ -28,8 +29,10 @@ use std::collections::BTreeMap; pub use announce::{AnnounceData, AnnounceEvent, AnnouncePolicy}; pub use configuration_instance_id::ConfigurationInstanceId; pub use driver::Driver; +pub use i2p::{I2pDestination, I2pPeerAddress}; pub use mode::PrivateMode; pub use number_of_bytes::NumberOfBytes; +pub use peer::PeerAddress; pub use policy::TrackerPolicy; pub use runtime_service_metadata::RuntimeServiceMetadata; pub use scrape::ScrapeData; diff --git a/packages/primitives/src/peer.rs b/packages/primitives/src/peer.rs index 0f3eac056..77a5f5ff9 100644 --- a/packages/primitives/src/peer.rs +++ b/packages/primitives/src/peer.rs @@ -13,7 +13,7 @@ //! //! peer::Peer { //! peer_id: PeerId(*b"-qB00000000000000000"), -//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), +//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), //! updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), //! uploaded: NumberOfBytes::new(0), //! downloaded: NumberOfBytes::new(0), @@ -29,12 +29,81 @@ use std::str::FromStr; use std::sync::Arc; use serde::Serialize; +use thiserror::Error; use torrust_clock::DurationSinceUnixEpoch; -use crate::{AnnounceEvent, NumberOfBytes, PeerId}; +use crate::{AnnounceEvent, I2pPeerAddress, NumberOfBytes, PeerId}; pub type PeerAnnouncement = Peer; +/// A peer endpoint on either the public Internet or I2P. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum PeerAddress { + Clearnet(SocketAddr), + I2p(I2pPeerAddress), +} + +impl PeerAddress { + /// Returns the clearnet port, or the conventional placeholder port `1` for I2P. + /// + /// I2P routes by Destination rather than by port. The placeholder is needed + /// by non-compact tracker responses and legacy peer representations. + #[must_use] + pub const fn port(&self) -> u16 { + match self { + Self::Clearnet(address) => address.port(), + // I2P clients ignore the port, but legacy tracker response parsers + // expect the key to exist in non-compact responses. + Self::I2p(_) => 1, + } + } + + /// Returns the peer's clearnet IP address, or `None` for an I2P peer. + #[must_use] + pub const fn ip(&self) -> Option { + match self { + Self::Clearnet(address) => Some(address.ip()), + Self::I2p(_) => None, + } + } + + /// Returns whether this is an I2P peer address. + #[must_use] + pub const fn is_i2p(&self) -> bool { + matches!(self, Self::I2p(_)) + } + + /// Returns the peer's clearnet socket address, or `None` for an I2P peer. + #[must_use] + pub const fn socket_addr(&self) -> Option { + match self { + Self::Clearnet(address) => Some(*address), + Self::I2p(_) => None, + } + } +} + +impl From for PeerAddress { + fn from(value: SocketAddr) -> Self { + Self::Clearnet(value) + } +} + +impl fmt::Display for PeerAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Clearnet(address) => address.fmt(f), + Self::I2p(address) => address.destination.fmt(f), + } + } +} + +impl Serialize for PeerAddress { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + #[derive(Debug, Serialize, Copy, Clone, PartialEq, Eq, Hash)] #[serde(rename_all_fields = "lowercase")] pub enum PeerRole { @@ -101,7 +170,7 @@ pub enum ParsePeerRoleError { /// /// peer::Peer { /// peer_id: PeerId(*b"-qB00000000000000000"), -/// peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), +/// peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), /// updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), /// uploaded: NumberOfBytes::new(0), /// downloaded: NumberOfBytes::new(0), @@ -109,13 +178,13 @@ pub enum ParsePeerRoleError { /// event: AnnounceEvent::Started, /// }; /// ``` -#[derive(Debug, Clone, Serialize, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] pub struct Peer { /// ID used by the downloader peer #[serde(serialize_with = "ser_peer_id")] pub peer_id: PeerId, - /// The IP and port this peer is listening on - pub peer_addr: SocketAddr, + /// The clearnet socket address or I2P Destination for this peer. + pub peer_addr: PeerAddress, /// The last time the the tracker receive an announce request from this peer (timestamp) #[serde(serialize_with = "ser_unix_time_value")] pub updated: DurationSinceUnixEpoch, @@ -203,7 +272,7 @@ pub trait ReadInfo { fn get_event(&self) -> AnnounceEvent; fn get_id(&self) -> PeerId; fn get_updated(&self) -> DurationSinceUnixEpoch; - fn get_address(&self) -> SocketAddr; + fn get_address(&self) -> &PeerAddress; } impl ReadInfo for Peer { @@ -227,8 +296,8 @@ impl ReadInfo for Peer { self.updated } - fn get_address(&self) -> SocketAddr { - self.peer_addr + fn get_address(&self) -> &PeerAddress { + &self.peer_addr } } @@ -253,8 +322,8 @@ impl ReadInfo for Arc { self.updated } - fn get_address(&self) -> SocketAddr { - self.peer_addr + fn get_address(&self) -> &PeerAddress { + &self.peer_addr } } @@ -283,12 +352,17 @@ impl Peer { } } - pub fn ip(&mut self) -> IpAddr { + /// Returns the peer's clearnet IP address, or `None` for an I2P peer. + #[must_use] + pub fn ip(&self) -> Option { self.peer_addr.ip() } - pub fn change_ip(&mut self, new_ip: &IpAddr) { - self.peer_addr = SocketAddr::new(*new_ip, self.peer_addr.port()); + /// Replaces the IP of a clearnet peer and leaves an I2P peer unchanged. + pub fn set_clearnet_ip(&mut self, new_ip: &IpAddr) { + if let PeerAddress::Clearnet(address) = &mut self.peer_addr { + address.set_ip(*new_ip); + } } pub fn mark_as_completed(&mut self) { @@ -314,8 +388,6 @@ impl Peer { use std::panic::Location; -use thiserror::Error; - /// Error returned when trying to convert an invalid peer id from another type. /// /// Usually because the source format does not contain 20 bytes. @@ -518,7 +590,7 @@ pub mod fixture { pub fn seeder() -> Self { let peer = Peer { peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -534,7 +606,7 @@ pub mod fixture { pub fn leecher() -> Self { let peer = Peer { peer_id: PeerId(*b"-qB00000000000000002"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -555,13 +627,13 @@ pub mod fixture { #[allow(dead_code)] #[must_use] pub fn with_peer_addr(mut self, peer_addr: &SocketAddr) -> Self { - self.peer.peer_addr = *peer_addr; + self.peer.peer_addr = (*peer_addr).into(); self } #[must_use] pub fn with_peer_address(mut self, peer_addr: SocketAddr) -> Self { - self.peer.peer_addr = peer_addr; + self.peer.peer_addr = peer_addr.into(); self } @@ -622,7 +694,7 @@ pub mod fixture { fn default() -> Self { Self { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -642,7 +714,6 @@ pub mod fixture { #[cfg(test)] pub mod test { - mod peer { use crate::peer::fixture::PeerBuilder; diff --git a/packages/rest-api-runtime-adapter/src/v1/adapters/torrent.rs b/packages/rest-api-runtime-adapter/src/v1/adapters/torrent.rs index 0b304af1f..010f95b90 100644 --- a/packages/rest-api-runtime-adapter/src/v1/adapters/torrent.rs +++ b/packages/rest-api-runtime-adapter/src/v1/adapters/torrent.rs @@ -32,7 +32,7 @@ impl TorrentQueryPort for TrackerTorrentQueryAdapter { async fn get_torrent_info(&self, info_hash: &InfoHash) -> Option { services::get_torrent_info(&self.in_memory_torrent_repository, info_hash) .await - .map(conversion::from_domain_info) + .map(|info| conversion::from_domain_info(&info)) } async fn get_torrents_page(&self, pagination: &Pagination) -> Vec { diff --git a/packages/rest-api-runtime-adapter/src/v1/conversion.rs b/packages/rest-api-runtime-adapter/src/v1/conversion.rs index 8eecb0ea9..d0fc7a216 100644 --- a/packages/rest-api-runtime-adapter/src/v1/conversion.rs +++ b/packages/rest-api-runtime-adapter/src/v1/conversion.rs @@ -9,7 +9,7 @@ use torrust_tracker_rest_api_protocol::v1::context::torrent::resources::torrent: /// Convert a domain [`domain_peer::Peer`] into a protocol [`protocol_peer::Peer`]. #[must_use] -pub fn from_domain_peer(value: domain_peer::Peer) -> protocol_peer::Peer { +pub fn from_domain_peer(value: &domain_peer::Peer) -> protocol_peer::Peer { #[allow(deprecated)] protocol_peer::Peer { peer_id: from_domain_peer_id(value.peer_id), @@ -35,8 +35,11 @@ pub fn from_domain_peer_id(peer_id: PeerId) -> protocol_peer::Id { /// Convert a domain [`Info`] into a protocol [`Torrent`]. #[must_use] -pub fn from_domain_info(info: Info) -> Torrent { - let peers: Option> = info.peers.map(|peers| peers.into_iter().map(from_domain_peer).collect()); +pub fn from_domain_info(info: &Info) -> Torrent { + let peers: Option> = info + .peers + .as_deref() + .map(|peers| peers.iter().map(from_domain_peer).collect()); Torrent { info_hash: info.info_hash.to_string(), @@ -80,7 +83,7 @@ mod tests { fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -92,7 +95,7 @@ mod tests { #[test] fn torrent_resource_should_be_converted_from_torrent_info() { assert_eq!( - from_domain_info(Info { + from_domain_info(&Info { info_hash: InfoHash::from_str("9e0217d0fa71c87332cd8bf9dbeabcb2c2cf3c4d").unwrap(), // DevSkim: ignore DS173237 seeders: 1, completed: 2, @@ -104,7 +107,7 @@ mod tests { seeders: 1, completed: 2, leechers: 3, - peers: Some(vec![from_domain_peer(sample_peer())]), + peers: Some(vec![from_domain_peer(&sample_peer())]), } ); } diff --git a/packages/swarm-coordination-registry/examples/bench_peers.rs b/packages/swarm-coordination-registry/examples/bench_peers.rs index 7235b1ac1..69125393f 100644 --- a/packages/swarm-coordination-registry/examples/bench_peers.rs +++ b/packages/swarm-coordination-registry/examples/bench_peers.rs @@ -16,7 +16,7 @@ fn make_peer(ip_last_octet: u8, port: u16, seed: u8) -> Peer { id[0] = ip_last_octet; Peer { peer_id: PeerId(id), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, ip_last_octet)), port), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, ip_last_octet)), port).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -46,7 +46,7 @@ fn bench_peers_excluding(num_peers: usize, limit: usize, iterations: u64) -> f64 rt.block_on(coordinator.handle_announcement(&peer)); } - let requesting_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 254)), 6999); + let requesting_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 254)), 6999).into(); // Warm up for _ in 0..1000 { diff --git a/packages/swarm-coordination-registry/src/lib.rs b/packages/swarm-coordination-registry/src/lib.rs index 992db6010..34cfcc5cd 100644 --- a/packages/swarm-coordination-registry/src/lib.rs +++ b/packages/swarm-coordination-registry/src/lib.rs @@ -68,7 +68,7 @@ pub(crate) mod tests { pub fn sample_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -81,7 +81,7 @@ pub(crate) mod tests { pub fn sample_peer_one() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -94,7 +94,7 @@ pub(crate) mod tests { pub fn sample_peer_two() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000002"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -120,7 +120,7 @@ pub(crate) mod tests { pub fn complete_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -134,7 +134,7 @@ pub(crate) mod tests { pub fn incomplete_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/swarm-coordination-registry/src/statistics/event/handler.rs b/packages/swarm-coordination-registry/src/statistics/event/handler.rs index 03952e137..bea313718 100644 --- a/packages/swarm-coordination-registry/src/statistics/event/handler.rs +++ b/packages/swarm-coordination-registry/src/statistics/event/handler.rs @@ -192,7 +192,7 @@ mod tests { // It returns a peer with the opposite role of the given peer. fn make_opposite_role_peer(peer: &Peer) -> Peer { - let mut opposite_role_peer = *peer; + let mut opposite_role_peer = peer.clone(); match peer.role() { PeerRole::Seeder => { @@ -491,7 +491,7 @@ mod tests { handle_event( Event::PeerAdded { info_hash: sample_info_hash(), - peer: old_peer, + peer: old_peer.clone(), }, &stats_repository, CurrentClock::now(), @@ -533,7 +533,7 @@ mod tests { handle_event( Event::PeerAdded { info_hash: sample_info_hash(), - peer, + peer: peer.clone(), }, &stats_repository, CurrentClock::now(), @@ -560,7 +560,7 @@ mod tests { handle_event( Event::PeerRemoved { info_hash: sample_info_hash(), - peer, + peer: peer.clone(), }, &stats_repository, CurrentClock::now(), @@ -588,7 +588,7 @@ mod tests { Event::PeerUpdated { info_hash: sample_info_hash(), old_peer: sample_peer(), - new_peer, + new_peer: new_peer.clone(), }, &stats_repository, CurrentClock::now(), diff --git a/packages/swarm-coordination-registry/src/swarm/coordinator.rs b/packages/swarm-coordination-registry/src/swarm/coordinator.rs index 562408af5..d3c636bfe 100644 --- a/packages/swarm-coordination-registry/src/swarm/coordinator.rs +++ b/packages/swarm-coordination-registry/src/swarm/coordinator.rs @@ -1,14 +1,13 @@ //! A swarm is a collection of peers that are all trying to download the same //! torrent. use std::collections::BTreeMap; -use std::net::SocketAddr; use std::sync::Arc; use torrust_clock::DurationSinceUnixEpoch; use torrust_info_hash::InfoHash; use torrust_tracker_primitives::peer::{self, Peer, PeerAnnouncement}; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::{AnnounceEvent, TrackerPolicy}; +use torrust_tracker_primitives::{AnnounceEvent, PeerAddress, TrackerPolicy}; use crate::event::Event; use crate::event::sender::Sender; @@ -16,7 +15,7 @@ use crate::event::sender::Sender; #[derive(Clone)] pub struct Coordinator { info_hash: InfoHash, - peers: BTreeMap>, + peers: BTreeMap>, metadata: SwarmMetadata, event_sender: Sender, } @@ -35,7 +34,7 @@ impl Coordinator { pub async fn handle_announcement(&mut self, incoming_announce: &PeerAnnouncement) { let _previous_peer = match peer::ReadInfo::get_event(incoming_announce) { AnnounceEvent::Started | AnnounceEvent::None | AnnounceEvent::Completed => { - self.upsert_peer(Arc::new(*incoming_announce)).await + self.upsert_peer(Arc::new(incoming_announce.clone())).await } AnnounceEvent::Stopped => self.remove_peer(&incoming_announce.peer_addr).await, }; @@ -52,7 +51,7 @@ impl Coordinator { } #[must_use] - pub fn get(&self, peer_addr: &SocketAddr) -> Option<&Arc> { + pub fn get(&self, peer_addr: &PeerAddress) -> Option<&Arc> { self.peers.get(peer_addr) } @@ -65,24 +64,16 @@ impl Coordinator { } #[must_use] - pub fn peers_excluding(&self, peer_addr: &SocketAddr, limit: Option) -> Vec> { + pub fn peers_excluding(&self, peer_addr: &PeerAddress, limit: Option) -> Vec> { + let peers = self + .peers + .values() + .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != peer_addr) + .filter(|peer| peer.peer_addr.is_i2p() == peer_addr.is_i2p()); + match limit { - Some(limit) => self - .peers - .values() - // Take peers which are not the client peer - .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) - // Limit the number of peers on the result - .take(limit) - .cloned() - .collect(), - None => self - .peers - .values() - // Take peers which are not the client peer - .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) - .cloned() - .collect(), + Some(limit) => peers.take(limit).cloned().collect(), + None => peers.cloned().collect(), } } @@ -157,7 +148,7 @@ impl Coordinator { async fn upsert_peer(&mut self, incoming_announce: Arc) -> Option> { let announcement = incoming_announce.clone(); - if let Some(previous_announce) = self.peers.insert(incoming_announce.peer_addr, incoming_announce) { + if let Some(previous_announce) = self.peers.insert(incoming_announce.peer_addr.clone(), incoming_announce) { let downloads_increased = self.update_metadata_on_update(&previous_announce, &announcement); self.trigger_peer_updated_event(&previous_announce, &announcement).await; @@ -176,7 +167,7 @@ impl Coordinator { } } - async fn remove_peer(&mut self, peer_addr: &SocketAddr) -> Option> { + async fn remove_peer(&mut self, peer_addr: &PeerAddress) -> Option> { if let Some(old_peer) = self.peers.remove(peer_addr) { self.update_metadata_on_removal(&old_peer); @@ -189,11 +180,11 @@ impl Coordinator { } #[must_use] - fn inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> Vec { + fn inactive_peers(&self, current_cutoff: DurationSinceUnixEpoch) -> Vec { self.peers .iter() .filter(|(_, peer)| peer::ReadInfo::get_updated(&**peer) <= current_cutoff) - .map(|(addr, _)| *addr) + .map(|(addr, _)| addr.clone()) .collect() } @@ -249,7 +240,7 @@ impl Coordinator { event_sender .send(Event::PeerAdded { info_hash: self.info_hash, - peer: *announcement.clone(), + peer: announcement.as_ref().clone(), }) .await; } @@ -260,7 +251,7 @@ impl Coordinator { event_sender .send(Event::PeerRemoved { info_hash: self.info_hash, - peer: *old_peer.clone(), + peer: old_peer.as_ref().clone(), }) .await; } @@ -271,8 +262,8 @@ impl Coordinator { event_sender .send(Event::PeerUpdated { info_hash: self.info_hash, - old_peer: *old_announce.clone(), - new_peer: *new_announce.clone(), + old_peer: old_announce.as_ref().clone(), + new_peer: new_announce.as_ref().clone(), }) .await; } @@ -283,7 +274,7 @@ impl Coordinator { event_sender .send(Event::PeerDownloadCompleted { info_hash: self.info_hash, - peer: *new_announce.clone(), + peer: new_announce.as_ref().clone(), }) .await; } @@ -321,9 +312,9 @@ mod tests { use std::sync::Arc; use torrust_clock::DurationSinceUnixEpoch; - use torrust_tracker_primitives::PeerId; use torrust_tracker_primitives::peer::fixture::PeerBuilder; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; + use torrust_tracker_primitives::{I2pDestination, I2pPeerAddress, PeerAddress, PeerId}; use crate::swarm::coordinator::Coordinator; use crate::tests::sample_info_hash; @@ -348,7 +339,7 @@ mod tests { let peer = PeerBuilder::default().build(); - assert_eq!(swarm.upsert_peer(peer.into()).await, None); + assert_eq!(swarm.upsert_peer(peer.clone().into()).await, None); } #[tokio::test] @@ -357,9 +348,9 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; - assert_eq!(swarm.upsert_peer(peer.into()).await, Some(Arc::new(peer))); + assert_eq!(swarm.upsert_peer(peer.clone().into()).await, Some(Arc::new(peer))); } #[tokio::test] @@ -368,7 +359,7 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.peers(None), [Arc::new(peer)]); } @@ -379,7 +370,7 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.get(&peer.peer_addr), Some(Arc::new(peer)).as_ref()); } @@ -390,7 +381,7 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.len(), 1); } @@ -401,7 +392,7 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; swarm.remove_peer(&peer.peer_addr).await; @@ -414,11 +405,11 @@ mod tests { let peer = PeerBuilder::default().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let old = swarm.remove_peer(&peer.peer_addr).await; - assert_eq!(old, Some(Arc::new(peer))); + assert_eq!(old, Some(Arc::new(peer.clone()))); assert_eq!(swarm.get(&peer.peer_addr), None); } @@ -439,17 +430,36 @@ mod tests { .with_peer_id(&PeerId(*b"-qB00000000000000001")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); - swarm.upsert_peer(peer1.into()).await; + swarm.upsert_peer(peer1.clone().into()).await; let peer2 = PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000002")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 6969)) .build(); - swarm.upsert_peer(peer2.into()).await; + swarm.upsert_peer(peer2.clone().into()).await; assert_eq!(swarm.peers_excluding(&peer2.peer_addr, None), [Arc::new(peer1)]); } + #[tokio::test] + async fn it_should_not_return_peers_from_a_different_network() { + let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); + let clearnet_peer = PeerBuilder::default().build(); + swarm.upsert_peer(clearnet_peer.clone().into()).await; + + let mut i2p_peer = PeerBuilder::default().with_peer_id(&PeerId(*b"-qB00000000000000002")).build(); + i2p_peer.peer_addr = PeerAddress::I2p(I2pPeerAddress { + destination: format!("{}.i2p", "A".repeat(516)).parse::().unwrap(), + }); + swarm.upsert_peer(i2p_peer.clone().into()).await; + + let peers_for_i2p = swarm.peers_excluding(&i2p_peer.peer_addr, None); + let peers_for_clearnet = swarm.peers_excluding(&clearnet_peer.peer_addr, None); + + assert_eq!(peers_for_i2p, []); + assert_eq!(peers_for_clearnet, []); + } + #[tokio::test] async fn it_should_count_inactive_peers() { let mut swarm = Coordinator::new(&sample_info_hash(), 0, None); @@ -459,7 +469,7 @@ mod tests { // Insert the peer let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let inactive_peers_total = swarm.count_inactive_peers(last_update_time + one_second); @@ -475,7 +485,7 @@ mod tests { // Insert the peer let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; // Remove peers not updated since one second after inserting the peer swarm.remove_inactive(last_update_time + one_second).await; @@ -492,7 +502,7 @@ mod tests { // Insert the peer let last_update_time = DurationSinceUnixEpoch::new(1_669_397_478_934, 0); let peer = PeerBuilder::default().last_updated_on(last_update_time).build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; // Remove peers not updated since one second before inserting the peer. swarm.remove_inactive(last_update_time.checked_sub(one_second).unwrap()).await; @@ -523,11 +533,11 @@ mod tests { let mut peer = PeerBuilder::leecher().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; peer.event = torrust_tracker_primitives::AnnounceEvent::Completed; - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert!(swarm.metadata().downloads() > 0); @@ -608,12 +618,12 @@ mod tests { let peer1 = PeerBuilder::default() .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); - swarm.upsert_peer(peer1.into()).await; + swarm.upsert_peer(peer1.clone().into()).await; let peer2 = PeerBuilder::default() .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 6969)) .build(); - swarm.upsert_peer(peer2.into()).await; + swarm.upsert_peer(peer2.clone().into()).await; assert_eq!(swarm.len(), 2); } @@ -629,13 +639,13 @@ mod tests { .with_peer_id(&PeerId(*b"-qB00000000000000001")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); - swarm.upsert_peer(peer1.into()).await; + swarm.upsert_peer(peer1.clone().into()).await; let peer2 = PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000002")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); - swarm.upsert_peer(peer2.into()).await; + swarm.upsert_peer(peer2.clone().into()).await; assert_eq!(swarm.len(), 1); } @@ -647,8 +657,8 @@ mod tests { let seeder = PeerBuilder::seeder().build(); let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(seeder.into()).await; - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; + swarm.upsert_peer(leecher.clone().into()).await; assert_eq!( swarm.metadata(), @@ -667,8 +677,8 @@ mod tests { let seeder = PeerBuilder::seeder().build(); let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(seeder.into()).await; - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; + swarm.upsert_peer(leecher.clone().into()).await; let (seeders, _leechers) = swarm.seeders_and_leechers(); @@ -682,8 +692,8 @@ mod tests { let seeder = PeerBuilder::seeder().build(); let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(seeder.into()).await; - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; + swarm.upsert_peer(leecher.clone().into()).await; let (_seeders, leechers) = swarm.seeders_and_leechers(); @@ -712,7 +722,7 @@ mod tests { let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(leecher.clone().into()).await; assert_eq!(swarm.metadata().leechers(), leechers + 1); } @@ -725,7 +735,7 @@ mod tests { let seeder = PeerBuilder::seeder().build(); - swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; assert_eq!(swarm.metadata().seeders(), seeders + 1); } @@ -739,7 +749,7 @@ mod tests { let seeder = PeerBuilder::seeder().build(); - swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; assert_eq!(swarm.metadata().downloads(), downloads); } @@ -757,7 +767,7 @@ mod tests { let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(leecher.clone().into()).await; let leechers = swarm.metadata().leechers(); @@ -772,7 +782,7 @@ mod tests { let seeder = PeerBuilder::seeder().build(); - swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; let seeders = swarm.metadata().seeders(); @@ -796,7 +806,7 @@ mod tests { let leecher = PeerBuilder::leecher().build(); - swarm.upsert_peer(leecher.into()).await; + swarm.upsert_peer(leecher.clone().into()).await; let leechers = swarm.metadata().leechers(); @@ -811,7 +821,7 @@ mod tests { let seeder = PeerBuilder::seeder().build(); - swarm.upsert_peer(seeder.into()).await; + swarm.upsert_peer(seeder.clone().into()).await; let seeders = swarm.metadata().seeders(); @@ -834,14 +844,14 @@ mod tests { let mut peer = PeerBuilder::leecher().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let leechers = swarm.metadata().leechers(); let seeders = swarm.metadata().seeders(); peer.left = NumberOfBytes::new(0); // Convert to seeder - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.metadata().seeders(), seeders + 1); assert_eq!(swarm.metadata().leechers(), leechers - 1); @@ -853,14 +863,14 @@ mod tests { let mut peer = PeerBuilder::seeder().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let leechers = swarm.metadata().leechers(); let seeders = swarm.metadata().seeders(); peer.left = NumberOfBytes::new(10); // Convert to leecher - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.metadata().leechers(), leechers + 1); assert_eq!(swarm.metadata().seeders(), seeders - 1); @@ -872,13 +882,13 @@ mod tests { let mut peer = PeerBuilder::leecher().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let downloads = swarm.metadata().downloads(); peer.event = torrust_tracker_primitives::AnnounceEvent::Completed; - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.metadata().downloads(), downloads + 1); } @@ -889,15 +899,15 @@ mod tests { let mut peer = PeerBuilder::leecher().build(); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; let downloads = swarm.metadata().downloads(); peer.event = torrust_tracker_primitives::AnnounceEvent::Completed; - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; assert_eq!(swarm.metadata().downloads(), downloads + 1); } @@ -924,11 +934,17 @@ mod tests { let mut event_sender_mock = MockEventSender::new(); - expect_event_sequence(&mut event_sender_mock, vec![Event::PeerAdded { info_hash, peer }]); + expect_event_sequence( + &mut event_sender_mock, + vec![Event::PeerAdded { + info_hash, + peer: peer.clone(), + }], + ); let mut swarm = Coordinator::new(&sample_info_hash(), 0, Some(Arc::new(event_sender_mock))); - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; } #[tokio::test] @@ -940,13 +956,22 @@ mod tests { expect_event_sequence( &mut event_sender_mock, - vec![Event::PeerAdded { info_hash, peer }, Event::PeerRemoved { info_hash, peer }], + vec![ + Event::PeerAdded { + info_hash, + peer: peer.clone(), + }, + Event::PeerRemoved { + info_hash, + peer: peer.clone(), + }, + ], ); let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); // Insert the peer - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; swarm.remove_peer(&peer.peer_addr).await; } @@ -960,13 +985,22 @@ mod tests { expect_event_sequence( &mut event_sender_mock, - vec![Event::PeerAdded { info_hash, peer }, Event::PeerRemoved { info_hash, peer }], + vec![ + Event::PeerAdded { + info_hash, + peer: peer.clone(), + }, + Event::PeerRemoved { + info_hash, + peer: peer.clone(), + }, + ], ); let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); // Insert the peer - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; // Peers not updated after this time will be removed let current_cutoff = peer.updated + DurationSinceUnixEpoch::from_secs(1); @@ -984,11 +1018,14 @@ mod tests { expect_event_sequence( &mut event_sender_mock, vec![ - Event::PeerAdded { info_hash, peer }, + Event::PeerAdded { + info_hash, + peer: peer.clone(), + }, Event::PeerUpdated { info_hash, - old_peer: peer, - new_peer: peer, + old_peer: peer.clone(), + new_peer: peer.clone(), }, ], ); @@ -996,17 +1033,17 @@ mod tests { let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); // Insert the peer - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; // Update the peer - swarm.upsert_peer(peer.into()).await; + swarm.upsert_peer(peer.clone().into()).await; } #[tokio::test] async fn it_should_trigger_an_event_when_a_peer_completes_a_download() { let info_hash = sample_info_hash(); let started_peer = PeerBuilder::leecher().with_event(Started).build(); - let completed_peer = started_peer.into_completed(); + let completed_peer = started_peer.clone().into_completed(); let mut event_sender_mock = MockEventSender::new(); @@ -1015,16 +1052,16 @@ mod tests { vec![ Event::PeerAdded { info_hash, - peer: started_peer, + peer: started_peer.clone(), }, Event::PeerUpdated { info_hash, - old_peer: started_peer, - new_peer: completed_peer, + old_peer: started_peer.clone(), + new_peer: completed_peer.clone(), }, Event::PeerDownloadCompleted { info_hash, - peer: completed_peer, + peer: completed_peer.clone(), }, ], ); @@ -1032,10 +1069,10 @@ mod tests { let mut swarm = Coordinator::new(&info_hash, 0, Some(Arc::new(event_sender_mock))); // Insert the peer - swarm.upsert_peer(started_peer.into()).await; + swarm.upsert_peer(started_peer.clone().into()).await; // Announce as completed - swarm.upsert_peer(completed_peer.into()).await; + swarm.upsert_peer(completed_peer.clone().into()).await; } } } diff --git a/packages/swarm-coordination-registry/src/swarm/registry.rs b/packages/swarm-coordination-registry/src/swarm/registry.rs index d6f78c0cb..cc89d1310 100644 --- a/packages/swarm-coordination-registry/src/swarm/registry.rs +++ b/packages/swarm-coordination-registry/src/swarm/registry.rs @@ -68,7 +68,7 @@ impl Registry { event_sender .send(Event::TorrentAdded { info_hash: *info_hash, - announcement: *peer, + announcement: peer.clone(), }) .await; } @@ -652,7 +652,7 @@ mod tests { for idx in 1..=75 { let peer = Peer { peer_id: numeric_peer_id(idx), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -723,7 +723,8 @@ mod tests { for idx in 2..=75 { let peer = Peer { peer_id: numeric_peer_id(idx), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, idx.try_into().unwrap())), 8080) + .into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -874,7 +875,7 @@ mod tests { fn into(self) -> TorrentEntryInfo { TorrentEntryInfo { swarm_metadata: self.metadata(), - peers: self.peers(None).iter().map(|peer| *peer.clone()).collect(), + peers: self.peers(None).iter().map(|peer| peer.as_ref().clone()).collect(), number_of_peers: self.len(), } } @@ -1366,9 +1367,12 @@ mod tests { vec![ Event::TorrentAdded { info_hash, - announcement: peer, + announcement: peer.clone(), + }, + Event::PeerAdded { + info_hash, + peer: peer.clone(), }, - Event::PeerAdded { info_hash, peer }, ], ); @@ -1389,9 +1393,12 @@ mod tests { vec![ Event::TorrentAdded { info_hash, - announcement: peer, + announcement: peer.clone(), + }, + Event::PeerAdded { + info_hash, + peer: peer.clone(), }, - Event::PeerAdded { info_hash, peer }, Event::TorrentRemoved { info_hash }, ], ); @@ -1415,10 +1422,16 @@ mod tests { vec![ Event::TorrentAdded { info_hash, - announcement: peer, + announcement: peer.clone(), + }, + Event::PeerAdded { + info_hash, + peer: peer.clone(), + }, + Event::PeerRemoved { + info_hash, + peer: peer.clone(), }, - Event::PeerAdded { info_hash, peer }, - Event::PeerRemoved { info_hash, peer }, Event::TorrentRemoved { info_hash }, ], ); diff --git a/packages/torrent-repository-benchmarking/benches/helpers/utils.rs b/packages/torrent-repository-benchmarking/benches/helpers/utils.rs index 99dd439cd..d477c4ae4 100644 --- a/packages/torrent-repository-benchmarking/benches/helpers/utils.rs +++ b/packages/torrent-repository-benchmarking/benches/helpers/utils.rs @@ -8,7 +8,7 @@ use torrust_tracker_primitives::{AnnounceEvent, NumberOfBytes, PeerId}; pub const DEFAULT_PEER: Peer = Peer { peer_id: PeerId([0; 20]), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080), + peer_addr: torrust_tracker_primitives::PeerAddress::Clearnet(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8080)), updated: DurationSinceUnixEpoch::from_secs(0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/torrent-repository-benchmarking/src/entry/peer_list.rs b/packages/torrent-repository-benchmarking/src/entry/peer_list.rs index aac071c6a..a1df7748e 100644 --- a/packages/torrent-repository-benchmarking/src/entry/peer_list.rs +++ b/packages/torrent-repository-benchmarking/src/entry/peer_list.rs @@ -1,9 +1,8 @@ //! A peer list. -use std::net::SocketAddr; use std::sync::Arc; use torrust_clock::DurationSinceUnixEpoch; -use torrust_tracker_primitives::{PeerId, peer}; +use torrust_tracker_primitives::{PeerAddress, PeerId, peer}; // code-review: the current implementation uses the peer Id as the ``BTreeMap`` // key. That would allow adding two identical peers except for the Id. @@ -61,13 +60,13 @@ impl PeerList { } #[must_use] - pub fn get_peers_excluding_addr(&self, peer_addr: &SocketAddr, limit: Option) -> Vec> { + pub fn get_peers_excluding_addr(&self, peer_addr: &PeerAddress, limit: Option) -> Vec> { limit.map_or_else( || { self.peers .values() // Take peers which are not the client peer - .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) + .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != peer_addr) .cloned() .collect() }, @@ -75,7 +74,7 @@ impl PeerList { self.peers .values() // Take peers which are not the client peer - .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != *peer_addr) + .filter(|peer| peer::ReadInfo::get_address(peer.as_ref()) != peer_addr) // Limit the number of peers on the result .take(limit) .cloned() @@ -127,9 +126,9 @@ mod tests { let peer = PeerBuilder::default().build(); - peer_list.upsert(peer.into()); + peer_list.upsert(peer.clone().into()); - assert_eq!(peer_list.upsert(peer.into()), Some(Arc::new(peer))); + assert_eq!(peer_list.upsert(peer.clone().into()), Some(Arc::new(peer))); } #[test] @@ -138,7 +137,7 @@ mod tests { let peer = PeerBuilder::default().build(); - peer_list.upsert(peer.into()); + peer_list.upsert(peer.clone().into()); assert_eq!(peer_list.get_all(None), [Arc::new(peer)]); } @@ -149,7 +148,7 @@ mod tests { let peer = PeerBuilder::default().build(); - peer_list.upsert(peer.into()); + peer_list.upsert(peer.clone().into()); assert_eq!(peer_list.get(&peer.peer_id), Some(Arc::new(peer)).as_ref()); } @@ -171,7 +170,7 @@ mod tests { let peer = PeerBuilder::default().build(); - peer_list.upsert(peer.into()); + peer_list.upsert(peer.clone().into()); peer_list.remove(&peer.peer_id); @@ -184,7 +183,7 @@ mod tests { let peer = PeerBuilder::default().build(); - peer_list.upsert(peer.into()); + peer_list.upsert(peer.clone().into()); peer_list.remove(&peer.peer_id); @@ -199,13 +198,13 @@ mod tests { .with_peer_id(&PeerId(*b"-qB00000000000000001")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 6969)) .build(); - peer_list.upsert(peer1.into()); + peer_list.upsert(peer1.clone().into()); let peer2 = PeerBuilder::default() .with_peer_id(&PeerId(*b"-qB00000000000000002")) .with_peer_addr(&SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 6969)) .build(); - peer_list.upsert(peer2.into()); + peer_list.upsert(peer2.clone().into()); assert_eq!(peer_list.get_peers_excluding_addr(&peer2.peer_addr, None), [Arc::new(peer1)]); } diff --git a/packages/torrent-repository-benchmarking/src/entry/single.rs b/packages/torrent-repository-benchmarking/src/entry/single.rs index 8d949698c..dba6cf606 100644 --- a/packages/torrent-repository-benchmarking/src/entry/single.rs +++ b/packages/torrent-repository-benchmarking/src/entry/single.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use torrust_clock::DurationSinceUnixEpoch; use torrust_tracker_primitives::peer::{self}; use torrust_tracker_primitives::swarm_metadata::SwarmMetadata; -use torrust_tracker_primitives::{AnnounceEvent, TrackerPolicy}; +use torrust_tracker_primitives::{AnnounceEvent, PeerAddress, TrackerPolicy}; use super::Entry; use crate::EntrySingle; @@ -46,7 +46,7 @@ impl Entry for EntrySingle { } fn get_peers_for_client(&self, client: &SocketAddr, limit: Option) -> Vec> { - self.swarm.get_peers_excluding_addr(client, limit) + self.swarm.get_peers_excluding_addr(&PeerAddress::Clearnet(*client), limit) } fn upsert_peer(&mut self, peer: &peer::Peer) -> bool { @@ -57,7 +57,7 @@ impl Entry for EntrySingle { drop(self.swarm.remove(&peer::ReadInfo::get_id(peer))); } AnnounceEvent::Completed => { - let previous = self.swarm.upsert(Arc::new(*peer)); + let previous = self.swarm.upsert(Arc::new(peer.clone())); // Don't count if peer was not previously known and not already completed. if previous.is_some_and(|p| p.event != AnnounceEvent::Completed) { self.downloaded += 1; @@ -67,7 +67,7 @@ impl Entry for EntrySingle { _ => { // `Started` event (first announced event) or // `None` event (announcements done at regular intervals). - drop(self.swarm.upsert(Arc::new(*peer))); + drop(self.swarm.upsert(Arc::new(peer.clone()))); } } diff --git a/packages/torrent-repository-benchmarking/tests/entry/mod.rs b/packages/torrent-repository-benchmarking/tests/entry/mod.rs index e06ad358b..a7871b235 100644 --- a/packages/torrent-repository-benchmarking/tests/entry/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/entry/mod.rs @@ -271,7 +271,7 @@ async fn it_should_handle_a_peer_completed_announcement_and_update_the_downloade let downloaded = torrent.get_stats().await.downloaded; let peers = torrent.get_peers(None).await; - let mut peer = **peers.first().expect("there should be a peer"); + let mut peer = peers.first().expect("there should be a peer").as_ref().clone(); let is_already_completed = peer.event == AnnounceEvent::Completed; @@ -302,7 +302,7 @@ async fn it_should_update_a_peer_as_a_seeder( let completed = u32::try_from(peers.iter().filter(|p| p.is_seeder()).count()).expect("it_should_not_be_so_many"); let peers = torrent.get_peers(None).await; - let mut peer = **peers.first().expect("there should be a peer"); + let mut peer = peers.first().expect("there should be a peer").as_ref().clone(); let is_already_non_left = peer.left == NumberOfBytes::new(0); @@ -334,7 +334,7 @@ async fn it_should_update_a_peer_as_incomplete( let incomplete = u32::try_from(peers.iter().filter(|p| !p.is_seeder()).count()).expect("it should not be so many"); let peers = torrent.get_peers(None).await; - let mut peer = **peers.first().expect("there should be a peer"); + let mut peer = peers.first().expect("there should be a peer").as_ref().clone(); let completed_already = peer.left == NumberOfBytes::new(0); @@ -365,18 +365,23 @@ async fn it_should_get_peers_excluding_the_client_socket( make(&mut torrent, makes).await; let peers = torrent.get_peers(None).await; - let mut peer = **peers.first().expect("there should be a peer"); + let mut peer = peers.first().expect("there should be a peer").as_ref().clone(); let socket = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081); // for this test, we should not already use this socket. - assert_ne!(peer.peer_addr, socket); + assert_ne!(peer.peer_addr, socket.into()); // it should get the peer as it dose not share the socket. - assert!(torrent.get_peers_for_client(&socket, None).await.contains(&peer.into())); + assert!( + torrent + .get_peers_for_client(&socket, None) + .await + .contains(&peer.clone().into()) + ); // set the address to the socket. - peer.peer_addr = socket; + peer.peer_addr = socket.into(); torrent.upsert_peer(&peer).await; // Add peer // It should not include the peer that has the same socket. diff --git a/packages/torrent-repository-benchmarking/tests/repository/mod.rs b/packages/torrent-repository-benchmarking/tests/repository/mod.rs index a8469413a..1d0df2a31 100644 --- a/packages/torrent-repository-benchmarking/tests/repository/mod.rs +++ b/packages/torrent-repository-benchmarking/tests/repository/mod.rs @@ -583,7 +583,7 @@ async fn it_should_remove_inactive_peers( // Verify that this new peer was inserted into the repository. { let entry = repo.get(&info_hash).await.expect("it_should_get_some"); - assert!(entry.get_peers(None).contains(&peer.into())); + assert!(entry.get_peers(None).contains(&peer.clone().into())); } // Remove peers that have not been updated since the timeout (120 seconds ago). diff --git a/packages/tracker-core/src/announce_handler.rs b/packages/tracker-core/src/announce_handler.rs index b4339f692..eda7e259e 100644 --- a/packages/tracker-core/src/announce_handler.rs +++ b/packages/tracker-core/src/announce_handler.rs @@ -27,7 +27,7 @@ //! //! let peer = peer::Peer { //! peer_id: PeerId(*b"-qB00000000000000001"), -//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), +//! peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081).into(), //! updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), //! uploaded: NumberOfBytes::new(0), //! downloaded: NumberOfBytes::new(0), @@ -163,7 +163,7 @@ impl AnnounceHandler { ) -> Result { self.whitelist_authorization.authorize(info_hash).await?; - peer.change_ip(&assign_ip_address_to_peer( + peer.set_clearnet_ip(&assign_ip_address_to_peer( remote_client_ip, self.config.net.external_ip.map(Into::into), )); @@ -314,7 +314,7 @@ mod tests { fn sample_peer_1() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8081).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -327,7 +327,7 @@ mod tests { fn sample_peer_2() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000002"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8082).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -340,7 +340,7 @@ mod tests { fn sample_peer_3() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000003"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 3)), 8082), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 3)), 8082).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/tracker-core/src/peer_tests.rs b/packages/tracker-core/src/peer_tests.rs index 6dcf08f14..82721c3bd 100644 --- a/packages/tracker-core/src/peer_tests.rs +++ b/packages/tracker-core/src/peer_tests.rs @@ -14,7 +14,7 @@ fn it_should_be_serializable() { let torrent_peer = peer::Peer { peer_id: PeerId(*b"-qB0000-000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: CurrentClock::now(), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/tracker-core/src/test_helpers.rs b/packages/tracker-core/src/test_helpers.rs index 4607eb205..459ebb811 100644 --- a/packages/tracker-core/src/test_helpers.rs +++ b/packages/tracker-core/src/test_helpers.rs @@ -69,7 +69,7 @@ pub(crate) mod tests { pub fn sample_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -105,7 +105,7 @@ pub(crate) mod tests { pub fn complete_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000001"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), @@ -119,7 +119,7 @@ pub(crate) mod tests { pub fn incomplete_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000002"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 2)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/tracker-core/src/torrent/services.rs b/packages/tracker-core/src/torrent/services.rs index 3f43f07d5..3e4817d23 100644 --- a/packages/tracker-core/src/torrent/services.rs +++ b/packages/tracker-core/src/torrent/services.rs @@ -105,7 +105,7 @@ pub async fn get_torrent_info( let peers = torrent_entry.lock().await.peers(None); - let peers = Some(peers.iter().map(|peer| **peer).collect()); + let peers = Some(peers.iter().map(|peer| peer.as_ref().clone()).collect()); Some(Info { info_hash: *info_hash, @@ -212,7 +212,7 @@ mod tests { fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/tracker-core/tests/common/fixtures.rs b/packages/tracker-core/tests/common/fixtures.rs index 6e3d2680b..dfdcc03ba 100644 --- a/packages/tracker-core/tests/common/fixtures.rs +++ b/packages/tracker-core/tests/common/fixtures.rs @@ -36,7 +36,7 @@ pub fn sample_info_hash() -> InfoHash { pub fn sample_peer() -> Peer { Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(remote_client_ip(), 8080), + peer_addr: SocketAddr::new(remote_client_ip(), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/packages/tracker-core/tests/common/test_env.rs b/packages/tracker-core/tests/common/test_env.rs index 855fa0abb..bd6779f18 100644 --- a/packages/tracker-core/tests/common/test_env.rs +++ b/packages/tracker-core/tests/common/test_env.rs @@ -142,7 +142,7 @@ impl TestEnv { } pub async fn increase_number_of_downloads(&mut self, peer: Peer, remote_client_ip: &IpAddr, info_hash: &InfoHash) { - let _announce_data = self.announce_peer_started(peer, remote_client_ip, info_hash).await; + let _announce_data = self.announce_peer_started(peer.clone(), remote_client_ip, info_hash).await; let announce_data = self.announce_peer_completed(peer, remote_client_ip, info_hash).await; assert_eq!(announce_data.stats.downloads(), 1); diff --git a/packages/udp-core/src/peer_builder.rs b/packages/udp-core/src/peer_builder.rs index 5bef7d48e..8cff393f7 100644 --- a/packages/udp-core/src/peer_builder.rs +++ b/packages/udp-core/src/peer_builder.rs @@ -18,7 +18,7 @@ pub fn from_request(announce_request: &torrust_tracker_udp_protocol::AnnounceReq peer::Peer { peer_id: torrust_tracker_primitives::PeerId(announce_request.peer_id.0), - peer_addr: SocketAddr::new(*peer_ip, announce_request.port.0.into()), + peer_addr: SocketAddr::new(*peer_ip, announce_request.port.0.into()).into(), updated: CurrentClock::now(), uploaded: torrust_tracker_primitives::NumberOfBytes::new(announce_request.bytes_uploaded.0.get()), downloaded: torrust_tracker_primitives::NumberOfBytes::new(announce_request.bytes_downloaded.0.get()), diff --git a/packages/udp-server/src/handlers/announce.rs b/packages/udp-server/src/handlers/announce.rs index 5cc6bf459..0ab061350 100644 --- a/packages/udp-server/src/handlers/announce.rs +++ b/packages/udp-server/src/handlers/announce.rs @@ -132,7 +132,7 @@ fn build_response( .peers .iter() .filter_map(|peer| { - if let IpAddr::V4(ip) = peer.peer_addr.ip() { + if let Some(IpAddr::V4(ip)) = peer.peer_addr.ip() { Some(ResponsePeer:: { ip_address: ip.into(), port: Port(peer.peer_addr.port().into()), @@ -157,7 +157,7 @@ fn build_response( .peers .iter() .filter_map(|peer| { - if let IpAddr::V6(ip) = peer.peer_addr.ip() { + if let Some(IpAddr::V6(ip)) = peer.peer_addr.ip() { Some(ResponsePeer:: { ip_address: ip.into(), port: Port(peer.peer_addr.port().into()), @@ -413,7 +413,10 @@ pub(crate) mod tests { .get_torrent_peers(&info_hash.0.into(), usize::MAX) .await; - assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V4(remote_client_ip), client_port)); + assert_eq!( + peers[0].peer_addr, + SocketAddr::new(IpAddr::V4(remote_client_ip), client_port).into() + ); } async fn add_a_torrent_peer_using_ipv6(in_memory_torrent_repository: &Arc) { @@ -770,7 +773,10 @@ pub(crate) mod tests { .await; // When using IPv6 the tracker converts the remote client ip into a IPv4 address - assert_eq!(peers[0].peer_addr, SocketAddr::new(IpAddr::V6(remote_client_ip), client_port)); + assert_eq!( + peers[0].peer_addr, + SocketAddr::new(IpAddr::V6(remote_client_ip), client_port).into() + ); } async fn add_a_torrent_peer_using_ipv4(in_memory_torrent_repository: &Arc) { @@ -943,7 +949,8 @@ pub(crate) mod tests { let peer_id = PeerId([255u8; 20]); let mut announcement = sample_peer(); announcement.peer_id = torrust_tracker_primitives::PeerId(peer_id.0); - announcement.peer_addr = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7e00, 1)), client_port); + announcement.peer_addr = + SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7e00, 1)), client_port).into(); let client_socket_addr = SocketAddr::new(IpAddr::V6(client_ip_v6), client_port); let mut server_socket_addr = config.udp_trackers.clone().unwrap()[0].bind_address; @@ -979,7 +986,7 @@ pub(crate) mod tests { server_service_binding.clone(), ), info_hash: torrust_info_hash::InfoHash::from(info_hash.0), - announcement, + announcement: announcement.clone(), }; announce_events_match(event, &expected_event) @@ -1043,7 +1050,7 @@ pub(crate) mod tests { // 1111:2222:3333:4444:5555:6666:1.2.3.4 // // ::127.0.0.1 is the IPV6 representation for the IPV4 address 127.0.0.1. - assert_eq!(Ok(peers[0].peer_addr.ip()), "::126.0.0.1".parse()); + assert_eq!(peers[0].peer_addr.ip(), "::126.0.0.1".parse::().ok()); } } } diff --git a/packages/udp-server/src/lib.rs b/packages/udp-server/src/lib.rs index 75a54e25a..2d8d8a27e 100644 --- a/packages/udp-server/src/lib.rs +++ b/packages/udp-server/src/lib.rs @@ -683,7 +683,7 @@ pub(crate) mod tests { pub fn sample_peer() -> peer::Peer { peer::Peer { peer_id: PeerId(*b"-qB00000000000000000"), - peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080), + peer_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(126, 0, 0, 1)), 8080).into(), updated: DurationSinceUnixEpoch::new(1_669_397_478_934, 0), uploaded: NumberOfBytes::new(0), downloaded: NumberOfBytes::new(0), diff --git a/project-words.txt b/project-words.txt index 61f3db162..e5fcd152f 100644 --- a/project-words.txt +++ b/project-words.txt @@ -29,6 +29,7 @@ EADDRINUSE EINVAL Eray Freebox +Frigyes Frostegård Garnham Gibibytes @@ -37,6 +38,7 @@ Graphviz Grcov HDRINCL Hydranode +I2P IPPROTO IPV6 Icelake @@ -79,6 +81,7 @@ Registar Rustls Ryzen SHLVL +SUBSESSIONS Seedable Shareaza Signedness @@ -261,6 +264,7 @@ infohash infohashes infoschema initialisation +inproxy intervali io_uring isready @@ -343,6 +347,7 @@ parallelisable parallelise parallelised parseable +pathlib peekable peerlist peersld @@ -396,6 +401,7 @@ rustc rustdoc rustfmt rustup +samv3 sarif savepath scanf @@ -414,6 +420,7 @@ socat socketaddr sockfd specialised +spoofable sqllite sqlx srcset @@ -423,6 +430,7 @@ subissue subkey subsec substeps +suffixless summarising supertrait syscall