diff --git a/console/tracker-client/docs/features/json-request-input/README.md b/console/tracker-client/docs/features/json-request-input/README.md index 44eb3f93b..daec1157a 100644 --- a/console/tracker-client/docs/features/json-request-input/README.md +++ b/console/tracker-client/docs/features/json-request-input/README.md @@ -66,7 +66,7 @@ cat announce.json | tracker_client udp announce 127.0.0.1:6969 --request-stdin "downloaded": 5678, "left": 0, "port": 6881, - "peer_addr": "10.0.0.1", + "ip": "10.0.0.1", "peer_id": "-RC00000000000000001", "compact": 1, "key": 42, @@ -77,7 +77,7 @@ cat announce.json | tracker_client udp announce 127.0.0.1:6969 --request-stdin Notes: -- HTTP uses `peer_addr` and `compact`. +- HTTP uses `ip` and `compact`. - UDP uses `ip_address`, `key`, and `peers_wanted`. - A shared schema can allow optional protocol-specific fields. diff --git a/console/tracker-client/src/console/clients/http/app.rs b/console/tracker-client/src/console/clients/http/app.rs index 59b6071df..1a903350c 100644 --- a/console/tracker-client/src/console/clients/http/app.rs +++ b/console/tracker-client/src/console/clients/http/app.rs @@ -148,8 +148,8 @@ enum Command { left: Option, #[arg(long, value_parser = parse_non_zero_port)] port: Option, - #[arg(long = "peer-addr")] - peer_addr: Option, + #[arg(long = "ip")] + ip: Option, #[arg(long = "peer-id", value_parser = parse_peer_id)] peer_id: Option, #[arg(long, value_enum)] @@ -173,7 +173,7 @@ struct AnnounceOptions { downloaded: Option, left: Option, port: Option, - peer_addr: Option, + ip: Option, peer_id: Option, compact: Option, output_format: OutputFormat, @@ -194,7 +194,7 @@ pub async fn run() -> anyhow::Result<()> { downloaded, left, port, - peer_addr, + ip, peer_id, compact, format, @@ -208,7 +208,7 @@ pub async fn run() -> anyhow::Result<()> { downloaded, left, port, - peer_addr, + ip, peer_id, compact, output_format: format, @@ -255,8 +255,8 @@ async fn announce_command(options: AnnounceOptions, timeout: Duration) -> anyhow if let Some(port) = options.port { query_builder = query_builder.with_port(port); } - if let Some(peer_addr) = options.peer_addr { - query_builder = query_builder.with_peer_addr(peer_addr); + if let Some(ip) = options.ip { + query_builder = query_builder.with_ip(ip); } if let Some(peer_id) = options.peer_id { query_builder = query_builder.with_peer_id(&peer_id); diff --git a/console/tracker-client/src/console/clients/unified/http.rs b/console/tracker-client/src/console/clients/unified/http.rs index 7a8951a61..5886f9461 100644 --- a/console/tracker-client/src/console/clients/unified/http.rs +++ b/console/tracker-client/src/console/clients/unified/http.rs @@ -66,8 +66,8 @@ pub enum Command { left: Option, #[arg(long, value_parser = parse_non_zero_port)] port: Option, - #[arg(long = "peer-addr")] - peer_addr: Option, + #[arg(long = "ip")] + ip: Option, #[arg(long = "peer-id", value_parser = parse_peer_id)] peer_id: Option, #[arg(long, value_enum)] @@ -91,7 +91,7 @@ struct AnnounceOptions { downloaded: Option, left: Option, port: Option, - peer_addr: Option, + ip: Option, peer_id: Option, compact: Option, output_format: OutputFormat, @@ -110,7 +110,7 @@ pub async fn run(command: Command) -> anyhow::Result<()> { downloaded, left, port, - peer_addr, + ip, peer_id, compact, format, @@ -124,7 +124,7 @@ pub async fn run(command: Command) -> anyhow::Result<()> { downloaded, left, port, - peer_addr, + ip, peer_id, compact, output_format: format, @@ -171,8 +171,8 @@ async fn announce_command(options: AnnounceOptions, timeout: Duration) -> anyhow if let Some(port) = options.port { query_builder = query_builder.with_port(port); } - if let Some(peer_addr) = options.peer_addr { - query_builder = query_builder.with_peer_addr(peer_addr); + if let Some(ip) = options.ip { + query_builder = query_builder.with_ip(ip); } if let Some(peer_id) = options.peer_id { query_builder = query_builder.with_peer_id(&peer_id); diff --git a/docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md b/docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md new file mode 100644 index 000000000..db0862498 --- /dev/null +++ b/docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md @@ -0,0 +1,65 @@ +--- +semantic-links: + skill-links: + - create-adr + related-artifacts: + - packages/http-protocol/src/v1/requests/announce.rs + - packages/axum-http-server/src/lib.rs + - docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md +--- + +# Accept only IP addresses (not DNS names) in the HTTP announce `ip` GET parameter + +- **Date**: 2026-07-16 +- **Issue**: [#1985](https://github.com/torrust/torrust-tracker/issues/1985) +- **Spec**: `docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md` + +## Context + +[BEP 3](https://www.bittorrent.org/beps/bep_0003.html) defines the `ip` announce parameter as: + +> 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. + +The current implementation parses the `ip` GET parameter by calling `IpAddr::from_str`. Any value +that is not a valid IP address (including DNS names) is silently dropped — the field is set to +`None` and the tracker falls back to using the connection IP. + +A policy decision is needed: should the tracker support DNS names, resolve them, or explicitly +restrict the parameter to IP addresses only? + +## Decision + +**Accept only IP addresses in the HTTP announce `ip` GET parameter.** + +Non-IP values (including DNS names) are silently ignored; the tracker falls back to the connection +IP. The restriction is documented in the module doc-comments. + +## Considered Alternatives + +| Approach | What | Pros | Cons | +| ---------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **A — IP only (this decision)** | Accept only valid `IpAddr` values; silently ignore non-IP values; document the restriction | Simple, predictable, no latency, no DoS risk, consistent with all major trackers | Deviates from the literal BEP 3 spec text | +| **B — Resolve DNS names** | Accept DNS names and resolve them to IPs at announce time | Closer to BEP 3 literal wording | Latency per announce, DoS amplification risk (attacker-controlled DNS lookups), complexity, no known client sends hostnames | +| **C — Accept and store hostnames** | Parse and store hostnames as strings alongside IPs | Closest to BEP 3 literal wording | Incompatible with the `IpAddr`-based peer list model; no client or tracker implements this; no BEP defines how hostnames are returned in responses | + +## Evidence from major trackers + +- **opentracker**: accepts only IP addresses in `ip`. Has a separate compile-time feature flag + (`WANT_IP_FROM_QUERY_STRING`) to optionally use the `ip` value for the peer's address; the type + accepted is always an IP address. +- **chihaya**: accepts only IP addresses in `ip`. +- **No known tracker** supports DNS name resolution in the announce `ip` parameter. + +## Consequences + +- **Positive**: No latency impact on announce handling. +- **Positive**: No DNS-based DoS attack surface. +- **Positive**: Consistent with opentracker, chihaya, and all other known tracker implementations. +- **Positive**: The `IpAddr`-based peer list model is preserved without changes. +- **Negative**: Deviates from the literal BEP 3 spec text ("or dns name"). Mitigated by clear + documentation and the fact that no known client sends a hostname in this field. + +A future issue may choose to return an explicit parse error for non-IP values (e.g. DNS names) +instead of silently ignoring them. Clients MUST NOT send hostnames in the `ip` field when +communicating with Torrust Tracker. diff --git a/docs/adrs/index.md b/docs/adrs/index.md index 200a1d48a..9723c324a 100644 --- a/docs/adrs/index.md +++ b/docs/adrs/index.md @@ -22,7 +22,8 @@ semantic-links: | [20260617093046](20260617093046_reject_wildcard_external_ip.md) | 2026-06-17 | Reject wildcard IPs as invalid `external_ip` values | Reject `0.0.0.0`/`::` in `external_ip` config at startup, change default to `None`. Fail fast on invalid config. | | [20260620000000](20260620000000_add_ipv6_v6only_config_option.md) | 2026-06-20 | Add `ipv6_v6only` config option for separate sockets | Add `ipv6_v6only` boolean flag to `UdpTracker` and `HttpTracker` configs, defaulting to `false` (dual-stack), so operators can opt into separate IPv4/IPv6 sockets. | | [20260623200526](20260623200526_adopt_contract-first_architecture_for_rest_api.md) | 2026-06-23 | Adopt a contract-first architecture for the REST API | Structure the REST API into four layers: protocol contract, application/use-case, runtime adapter, and transport adapter. Enables a future tracker-agnostic REST API standard. | -| [20260629000000](20260629000000_adopt_independent_package_versioning.md) | 2026-06-29 | Adopt independent package versioning | All workspace packages version independently. Path dependencies guarantee compatibility, so linked versions are unnecessary. Enables per-package publishing and aligns with EPIC #1669 extraction goals. | +| [20260629000000](20260629000000_adopt_independent_package_versioning.md) | 2026-06-29 | Adopt independent package versioning | All workspace packages version independently. Path dependencies guarantee compatibility, so linked versions are unnecessary. Enables per-package publishing and aligns with EPIC #1669 extraction goals. | +| [20260716000000](20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md) | 2026-07-16 | Accept only IP addresses in HTTP announce `ip` param | The HTTP announce `ip` GET parameter accepts only valid `IpAddr` values; DNS names are silently ignored. Matches de-facto standard of opentracker, chihaya, and all other known trackers. | ## ADR Lifecycle diff --git a/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md b/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md index 3957fd81d..35915d75b 100644 --- a/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md +++ b/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/ISSUE.md @@ -137,51 +137,55 @@ Status values: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`. | ID | Status | Task | Notes / Expected Output | | --- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| T1 | TODO | Rename `PEER_ADDR` constant and `"peer_addr"` wire string to `IP` / `"ip"` | `packages/http-protocol/src/v1/requests/announce.rs`. Also fix the hardcoded `"peer_addr"` literal in the `Display` impl (line 307) to use the renamed `IP` constant instead of a string literal. | -| T2 | TODO | Rename struct field `peer_addr` → `ip` on `Announce` | Same file; update all construction and match sites. Also fix the doc comment on the `Announce` struct (line 83) which incorrectly claims `peer_addr` is "as per BEP 3" — BEP 3 uses `ip`. | -| T3 | TODO | Rename `with_peer_addr` → `with_ip` on `AnnounceBuilder`; update `with_default_values` | Same file | -| T4 | TODO | Rename `extract_peer_addr` → `extract_ip`; update call sites | Same file | -| T5 | TODO | Update the `NOTICE` and parameter table in `packages/axum-http-server/src/lib.rs` | Replace incorrect BEP 15 reference with correct BEP 3 `ip` description | -| T6 | TODO | Update sample URLs in doc-comments from `peer_addr=` to `ip=` | `packages/axum-http-server/src/lib.rs`, `extractors/announce_request.rs`, `packages/tracker-core/src/torrent/mod.rs` | -| T7 | TODO | Update test fixtures and inline URL strings that use `peer_addr=` | `packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs`, `packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs`, `packages/axum-http-server/src/v1/extractors/announce_request.rs` (inline test query string) | -| T8 | TODO | Commit the ADR to `docs/adrs/` | File: `docs/adrs/YYYYMMDD_accept_only_ip_addresses_in_http_announce_ip_param.md` | -| T9 | TODO | Run `cargo test --workspace` — no regressions | All tests pass | -| T10 | TODO | Run `linter all` | Must exit `0` | -| T11 | TODO | Rename test function `should_not_fail_when_the_peer_address_param_is_invalid` | Rename to `should_not_fail_when_the_ip_param_is_invalid` in `packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs` | +| T1 | DONE | Rename `PEER_ADDR` constant and `"peer_addr"` wire string to `IP` / `"ip"` | `packages/http-protocol/src/v1/requests/announce.rs`. Also fix the hardcoded `"peer_addr"` literal in the `Display` impl (line 307) to use the renamed `IP` constant instead of a string literal. | +| T2 | DONE | Rename struct field `peer_addr` → `ip` on `Announce` | Same file; update all construction and match sites. Also fix the doc comment on the `Announce` struct (line 83) which incorrectly claims `peer_addr` is "as per BEP 3" — BEP 3 uses `ip`. | +| T3 | DONE | Rename `with_peer_addr` → `with_ip` on `AnnounceBuilder`; update `with_default_values` | Same file | +| T4 | DONE | Rename `extract_peer_addr` → `extract_ip`; update call sites | Same file | +| T5 | DONE | Update the `NOTICE` and parameter table in `packages/axum-http-server/src/lib.rs` | Replace incorrect BEP 15 reference with correct BEP 3 `ip` description | +| T6 | DONE | Update sample URLs in doc-comments from `peer_addr=` to `ip=` | `packages/axum-http-server/src/lib.rs`, `extractors/announce_request.rs`, `packages/tracker-core/src/torrent/mod.rs` | +| T7 | DONE | Update test fixtures and inline URL strings that use `peer_addr=` | `packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs`, `packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs`, `packages/axum-http-server/src/v1/extractors/announce_request.rs` (inline test query string) | +| T8 | DONE | Rename `--peer-addr` CLI flag to `--ip` in tracker-client binaries | `console/tracker-client/src/console/clients/http/app.rs`, `console/tracker-client/src/console/clients/unified/http.rs`. Also rename `peer_addr` CLI arg struct field and `AnnounceOptions` field to `ip`. | +| T9 | DONE | Update JSON key in tracker-client docs from `peer_addr` to `ip` | `console/tracker-client/docs/features/json-request-input/README.md` | +| T10 | DONE | Commit the ADR to `docs/adrs/` | File: `docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md` | +| T11 | DONE | Run `cargo test --workspace` — no regressions | All tests pass | +| T12 | DONE | Run `linter all` | Must exit `0` | +| T13 | DONE | Rename test function `should_not_fail_when_the_peer_address_param_is_invalid` | Rename to `should_not_fail_when_the_ip_param_is_invalid` in `packages/axum-http-server/tests/server/v1/contract/for_all_config_modes/receiving_an_announce_request.rs` | ## Progress Tracking ### Workflow Checkpoints -- [ ] Spec drafted in `docs/issues/drafts/` -- [ ] Spec reviewed and approved by user/maintainer -- [ ] GitHub issue created and issue number added to this spec +- [x] Spec drafted in `docs/issues/drafts/` +- [x] Spec reviewed and approved by user/maintainer +- [x] GitHub issue created and issue number added to this spec - [ ] (Optional, recommended for complex issues) Spec-only PR merged into `develop` before implementation -- [ ] Implementation completed -- [ ] Automatic verification completed (`linter all`, relevant tests, and any pre-push checks) -- [ ] Manual verification scenarios executed and recorded (status + evidence) -- [ ] Acceptance criteria reviewed after implementation and updated with evidence +- [x] Implementation completed +- [x] Automatic verification completed (`linter all`, relevant tests, and any pre-push checks) +- [x] Manual verification scenarios executed and recorded (status + evidence) +- [x] Acceptance criteria reviewed after implementation and updated with evidence - [ ] Reviewer validated acceptance criteria and updated checkboxes -- [ ] Committer verified spec progress is up to date before commit +- [x] Committer verified spec progress is up to date before commit - [ ] Issue closed and spec moved from `docs/issues/open/` to `docs/issues/closed/` ### Progress Log - 2026-07-15 00:00 UTC - Copilot/User - Spec drafted; ADR embedded as a section pending extraction to `docs/adrs/` during implementation. +- 2026-07-16 00:00 UTC - Copilot/User - Spec updated with user feedback (CLI flag renamed to `--ip`; JSON doc key renamed to `ip`; ADR date set to 2026-07-16). Implementation completed. All pre-commit checks pass. +- 2026-07-16 16:16 UTC - Copilot/User - Manual verification M1/M2/M3 executed against local tracker build. All scenarios pass. Evidence recorded in `manual-verification.md`. ## Acceptance Criteria -- [ ] AC1: An HTTP announce request using `ip=
` is correctly parsed — the `ip` field on the `Announce` struct is populated. -- [ ] AC2: An HTTP announce request using the old `peer_addr=
` parameter no longer populates the field (the old name is not recognised). -- [ ] AC3: The Rust struct field, builder method, extractor function, and constant all use the name `ip` (no remaining `peer_addr` references for the wire parameter). The `Display` impl uses the `IP` constant rather than a hardcoded string literal. -- [ ] AC4: The `NOTICE` in `packages/axum-http-server/src/lib.rs` accurately describes the `ip` parameter with a correct BEP 3 reference (no BEP 15 mention for this parameter). -- [ ] AC5: All sample URLs in documentation use `ip=` instead of `peer_addr=`. -- [ ] AC6: The ADR `docs/adrs/YYYYMMDD_accept_only_ip_addresses_in_http_announce_ip_param.md` is committed. -- [ ] AC7: `linter all` exits with code `0`. -- [ ] AC8: Relevant tests pass with no regressions. -- [ ] Manual verification scenarios are executed and documented (status + evidence). -- [ ] Acceptance criteria are re-reviewed after implementation and reflect actual behaviour. -- [ ] Documentation is updated when behaviour/workflow changes. +- [x] AC1: An HTTP announce request using `ip=
` is correctly parsed — the `ip` field on the `Announce` struct is populated. +- [x] AC2: An HTTP announce request using the old `peer_addr=
` parameter no longer populates the field (the old name is not recognised). +- [x] AC3: The Rust struct field, builder method, extractor function, and constant all use the name `ip` (no remaining `peer_addr` references for the wire parameter). The `Display` impl uses the `IP` constant rather than a hardcoded string literal. +- [x] AC4: The `NOTICE` in `packages/axum-http-server/src/lib.rs` accurately describes the `ip` parameter with a correct BEP 3 reference (no BEP 15 mention for this parameter). +- [x] AC5: All sample URLs in documentation use `ip=` instead of `peer_addr=`. +- [x] AC6: The ADR `docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md` is committed. +- [x] AC7: `linter all` exits with code `0`. +- [x] AC8: Relevant tests pass with no regressions. +- [x] Manual verification scenarios are executed and documented (status + evidence). +- [x] Acceptance criteria are re-reviewed after implementation and reflect actual behaviour. +- [x] Documentation is updated when behaviour/workflow changes. ## Verification Plan @@ -195,24 +199,24 @@ Status values: `TODO`, `IN_PROGRESS`, `BLOCKED`, `DONE`. Status values: `TODO`, `IN_PROGRESS`, `DONE`, `FAILED`, `BLOCKED`. -| ID | Scenario | Command/Steps | Expected Result | Status | Evidence | -| --- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------ | -------- | -| M1 | Announce with `ip=
` — field is parsed | `curl -s "http://localhost:7070/announce?info_hash=...&peer_id=...&port=6881&ip=2.137.87.41"` and check tracker logs | Tracker logs show `ip` was parsed | TODO | | -| M2 | Announce with old `peer_addr=
` — field is ignored | Replace `ip=` with `peer_addr=` in M1 URL | Tracker ignores the parameter (no parse error, field is `None`) | TODO | | -| M3 | Announce with `ip=hostname.example.com` — non-IP is silently ignored | Use a DNS name as the `ip` value | Field is `None`; no error returned | TODO | | +| ID | Scenario | Command/Steps | Expected Result | Status | Evidence | +| --- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------ | ------------------------------------------------------- | +| M1 | Announce with `ip=
` — field is parsed | `curl -s "http://localhost:7070/announce?info_hash=...&peer_id=...&port=6881&ip=2.137.87.41"` and check tracker logs | Tracker logs show `ip` was parsed | DONE | See [manual-verification.md](manual-verification.md#m1) | +| M2 | Announce with old `peer_addr=
` — field is ignored | Replace `ip=` with `peer_addr=` in M1 URL | Tracker ignores the parameter (no parse error, field is `None`) | DONE | See [manual-verification.md](manual-verification.md#m2) | +| M3 | Announce with `ip=hostname.example.com` — non-IP is silently ignored | Use a DNS name as the `ip` value | Field is `None`; no error returned | DONE | See [manual-verification.md](manual-verification.md#m3) | ### Acceptance Verification -| AC ID | Status (`TODO`/`DONE`) | Evidence | -| ----- | ---------------------- | -------- | -| AC1 | TODO | | -| AC2 | TODO | | -| AC3 | TODO | | -| AC4 | TODO | | -| AC5 | TODO | | -| AC6 | TODO | | -| AC7 | TODO | | -| AC8 | TODO | | +| AC ID | Status (`TODO`/`DONE`) | Evidence | +| ----- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| AC1 | DONE | Verified by `it_should_extract_the_announce_request_from_the_url_query_params` in `announce_request.rs` test using `ip=` | +| AC2 | DONE | `PEER_ADDR` constant removed; `extract_peer_addr` → `extract_ip` reads `IP = "ip"` constant | +| AC3 | DONE | `grep peer_addr` across protocol/server/client sources returns no wire-param references | +| AC4 | DONE | `packages/axum-http-server/src/lib.rs` NOTICE updated to reference BEP 3 | +| AC5 | DONE | All sample URLs updated in lib.rs, extractor, torrent/mod.rs, tracker-client docs | +| AC6 | DONE | `docs/adrs/20260716000000_accept_only_ip_addresses_in_http_announce_ip_param.md` created | +| AC7 | DONE | `linter all` exits `0` | +| AC8 | DONE | All pre-commit checks pass; 0 test failures | ## Risks and Trade-offs diff --git a/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/manual-verification.md b/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/manual-verification.md new file mode 100644 index 000000000..9a3f63ce1 --- /dev/null +++ b/docs/issues/open/1985-rename-peer-addr-to-ip-in-http-announce-request/manual-verification.md @@ -0,0 +1,108 @@ +# Manual Verification — Issue #1985 + +**Date**: 2026-07-16 +**Branch**: `1985-rename-peer-addr-to-ip-in-http-announce-request` +**Tracker**: local build (`./target/debug/torrust-tracker`, default dev config on `http://127.0.0.1:7070`) + +--- + +## Setup + +```bash +# Build +cargo build --bin torrust-tracker + +# Clean DB and start tracker +rm -f ./storage/tracker/lib/database/sqlite3.db +RUST_LOG=info ./target/debug/torrust-tracker & + +# Test values +BASE="http://127.0.0.1:7070" +INFO_HASH_ENC='%3b%24U%04%cf%5f%11%bb%db%e1%20%1c%eajk%f4Z%ee%1b%c0' # cspell:disable-line +PEER_ID='-RC3000-000000000001' +``` + +--- + +## M1 — Announce with `ip=
` (valid IP accepted) + +**Command**: + +```bash +curl -s "${BASE}/announce?info_hash=${INFO_HASH_ENC}&peer_id=${PEER_ID}&port=6881&uploaded=0&downloaded=0&left=0&event=started&compact=1&ip=2.137.87.41" +``` + +**Tracker log** (HTTP 200, announce processed): + +```text +INFO request{...&ip=2.137.87.41 ...}: HTTP TRACKER: request ... +INFO request{...&ip=2.137.87.41 ...}: HTTP TRACKER: response ... status_code=200 OK +``` + +**Response**: + +```text +d8:completei1e10:incompletei0e8:intervali120e12:min intervali120e5:peers0:6:peers60:e +``` + +**Result**: ✅ PASS — valid bencoded announce response returned; no parse error. + +--- + +## M2 — Announce with old `peer_addr=
` (param ignored) + +**Command**: + +```bash +curl -s "${BASE}/announce?info_hash=${INFO_HASH_ENC}&peer_id=${PEER_ID}&port=6881&uploaded=0&downloaded=0&left=0&event=started&compact=1&peer_addr=2.137.87.41" +``` + +**Tracker log** (HTTP 200, `peer_addr=` visible in URI but tracker processes request normally): + +```text +INFO request{...&peer_addr=2.137.87.41 ...}: HTTP TRACKER: request ... +INFO request{...&peer_addr=2.137.87.41 ...}: HTTP TRACKER: response ... status_code=200 OK +``` + +**Response**: + +```text +d8:completei1e10:incompletei0e8:intervali120e12:min intervali120e5:peers0:6:peers60:e +``` + +**Result**: ✅ PASS — old `peer_addr=` parameter is silently ignored; no failure reason returned. + +--- + +## M3 — Announce with `ip=hostname.example.com` (DNS name silently ignored) + +**Command**: + +```bash +curl -s "${BASE}/announce?info_hash=${INFO_HASH_ENC}&peer_id=${PEER_ID}&port=6881&uploaded=0&downloaded=0&left=0&event=started&compact=1&ip=hostname.example.com" +``` + +**Tracker log** (HTTP 200, DNS name visible in URI but tracker processes request normally): + +```text +INFO request{...&ip=hostname.example.com ...}: HTTP TRACKER: request ... +INFO request{...&ip=hostname.example.com ...}: HTTP TRACKER: response ... status_code=200 OK +``` + +**Response**: + +```text +d8:completei1e10:incompletei0e8:intervali120e12:min intervali120e5:peers0:6:peers60:e +``` + +**Result**: ✅ PASS — DNS name in `ip=` is silently dropped (field set to `None`); no failure reason returned; announce proceeds using connection IP. + +--- + +## Summary + +| ID | Scenario | Result | +| --- | ---------------------------------------------------------------------- | ------- | +| M1 | `ip=2.137.87.41` — valid IP accepted, normal announce response | ✅ PASS | +| M2 | `peer_addr=2.137.87.41` — old param silently ignored, normal response | ✅ PASS | +| M3 | `ip=hostname.example.com` — DNS name silently ignored, normal response | ✅ PASS | diff --git a/packages/axum-http-server/src/lib.rs b/packages/axum-http-server/src/lib.rs index 299d8ac68..cbb6e3f9a 100644 --- a/packages/axum-http-server/src/lib.rs +++ b/packages/axum-http-server/src/lib.rs @@ -44,7 +44,7 @@ //! Parameter | Type | Description | Required | Default | Example //! ---|---|---|---|---|--- //! [`info_hash`](torrust_tracker_http_protocol::v1::requests::announce::Announce::info_hash) | percent encoded of 20-byte array | The `Info Hash` of the torrent. | Yes | No | `%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00` -//! `peer_addr` | string |The IP address of the peer. | No | No | `2.137.87.41` +//! [`ip`](torrust_tracker_http_protocol::v1::requests::announce::Announce::ip) | string |The IP address of the peer (BEP 3). | No | No | `2.137.87.41` //! [`downloaded`](torrust_tracker_http_protocol::v1::requests::announce::Announce::downloaded) | positive integer |The number of bytes downloaded by the peer. | No | `0` | `0` //! [`uploaded`](torrust_tracker_http_protocol::v1::requests::announce::Announce::uploaded) | positive integer | The number of bytes uploaded by the peer. | No | `0` | `0` //! [`peer_id`](torrust_tracker_http_protocol::v1::requests::announce::Announce::peer_id) | percent encoded of 20-byte array | The ID of the peer. | Yes | No | `-qB00000000000000001` @@ -62,13 +62,12 @@ //! > tracker assigns default values to the optional parameters if they are not //! > provided. //! -//! > **NOTICE**: the `peer_addr` parameter is not part of the original -//! > specification. But the peer IP was added in the -//! > [UDP Tracker protocol](https://www.bittorrent.org/beps/bep_0015.html). It is -//! > used to provide the peer's IP address to the tracker, but it is ignored by -//! > the tracker. The tracker uses the IP address of the peer that sent the -//! > request or the right-most-ip in the `X-Forwarded-For` header if the tracker -//! > is behind a reverse proxy. +//! > **NOTICE**: the [`ip`](torrust_tracker_http_protocol::v1::requests::announce::Announce::ip) +//! > parameter is defined in [BEP 03](https://www.bittorrent.org/beps/bep_0003.html). +//! > It is used to provide the peer's IP address to the tracker, but it is +//! > ignored by the tracker. The tracker uses the IP address of the peer that +//! > sent the request or the right-most-ip in the `X-Forwarded-For` header if +//! > the tracker is behind a reverse proxy. //! //! > **NOTICE**: the maximum number of peers that the tracker can return per //! > announce response is controlled by the `max_peers_per_announce` field in @@ -93,7 +92,7 @@ //! //! A sample `GET` `announce` request: //! -//! +//! //! //! **Sample non-compact response** //! 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 479b72020..b6072d29c 100644 --- a/packages/axum-http-server/src/v1/extractors/announce_request.rs +++ b/packages/axum-http-server/src/v1/extractors/announce_request.rs @@ -12,7 +12,7 @@ //! //! **Sample announce request** //! -//! +//! //! //! **Sample error response** //! @@ -22,7 +22,7 @@ //! d14:failure reason149:Bad request. Cannot parse query params for announce request: missing query params for announce request in src/servers/http/v1/extractors/announce_request.rs:54:23e //! ``` //! -//! Invalid query param (`info_hash`): +//! Invalid query param (`info_hash`): //! //! ```text //! d14:failure reason240:Bad request. Cannot parse query params for announce request: invalid param value invalid for info_hash in not enough bytes for infohash: got 7 bytes, expected 20 src/shared/bit_torrent/info_hash.rs:240:27, src/servers/http/v1/requests/announce.rs:182:42e @@ -103,7 +103,7 @@ mod tests { #[test] fn it_should_extract_the_announce_request_from_the_url_query_params() { - let raw_query = "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&peer_addr=2.137.87.41&downloaded=0&uploaded=0&peer_id=-qB00000000000000001&port=17548&left=0&event=completed&compact=0&numwant=50"; + let raw_query = "info_hash=%3B%24U%04%CF%5F%11%BB%DB%E1%20%1C%EAjk%F4Z%EE%1B%C0&ip=2.137.87.41&downloaded=0&uploaded=0&peer_id=-qB00000000000000001&port=17548&left=0&event=completed&compact=0&numwant=50"; let announce = extract_announce_from(Some(raw_query)).unwrap(); @@ -113,7 +113,7 @@ mod tests { info_hash: InfoHash::from_str("3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0").unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, - peer_addr: Some(IpAddr::V4(Ipv4Addr::new(2, 137, 87, 41))), + ip: Some(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 6dcc70d9e..73e4868bd 100644 --- a/packages/axum-http-server/src/v1/handlers/announce.rs +++ b/packages/axum-http-server/src/v1/handlers/announce.rs @@ -220,7 +220,7 @@ mod tests { info_hash: sample_info_hash(), peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, - peer_addr: None, + ip: None, downloaded: None, uploaded: None, left: None, diff --git a/packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs b/packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs index c2ee4ada5..13458547f 100644 --- a/packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs +++ b/packages/axum-http-server/tests/server/v1/contract/configured_as_private.rs @@ -81,7 +81,7 @@ mod and_receiving_an_announce_request { let response = Client::new(env.base_url(), Duration::from_secs(5)).unwrap() .get(&format!( - "announce/{invalid_key}?info_hash=%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00&peer_addr=2.137.87.41&downloaded=0&uploaded=0&peer_id=-qB00000000000000001&port=17548&left=0&event=completed&compact=0" + "announce/{invalid_key}?info_hash=%81%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00&ip=2.137.87.41&downloaded=0&uploaded=0&peer_id=-qB00000000000000001&port=17548&left=0&event=completed&compact=0" )) .await.unwrap(); 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 54bedcd85..bbd6c68c6 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 @@ -186,7 +186,7 @@ async fn should_fail_when_the_info_hash_param_is_invalid() { for invalid_value in &invalid_info_hashes() { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact=0", invalid_value, percent_encode_byte_array(&AnnounceBuilder::default().query().peer_id.0), AnnounceBuilder::default().query().port, @@ -206,10 +206,10 @@ async fn should_fail_when_the_info_hash_param_is_invalid() { } #[tokio::test] -async fn should_not_fail_when_the_peer_address_param_is_invalid() { +async fn should_not_fail_when_the_ip_param_is_invalid() { logging::setup(); - // AnnounceQuery does not even contain the `peer_addr` + // AnnounceQuery does not even contain the `ip` param when it is invalid // The peer IP is obtained in two ways: // 1. If tracker is NOT running `on_reverse_proxy` from the remote client IP. // 2. If tracker is running `on_reverse_proxy` from `X-Forwarded-For` request HTTP header. @@ -220,7 +220,7 @@ async fn should_not_fail_when_the_peer_address_param_is_invalid() { let env = Started::new(&core_config, &http_tracker_config).await; let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact=0", percent_encode_byte_array(&AnnounceBuilder::default().query().info_hash.bytes()), percent_encode_byte_array(&AnnounceBuilder::default().query().peer_id.0), AnnounceBuilder::default().query().port, @@ -255,7 +255,7 @@ async fn should_fail_when_the_downloaded_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&downloaded={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&downloaded={}&event=started&compact=0", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -288,7 +288,7 @@ async fn should_fail_when_the_uploaded_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&uploaded={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&uploaded={}&event=started&compact=0", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -327,7 +327,7 @@ async fn should_fail_when_the_peer_id_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact=0", default_info_hash, invalid_value, default_port, "192.168.1.88", ); @@ -359,7 +359,7 @@ async fn should_fail_when_the_port_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact=0", default_info_hash, default_peer_id, invalid_value, "192.168.1.88", ); @@ -392,7 +392,7 @@ async fn should_fail_when_the_left_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&left={}&event=started&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&left={}&event=started&compact=0", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -433,7 +433,7 @@ async fn should_fail_when_the_event_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event={}&compact=0", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event={}&compact=0", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -466,7 +466,7 @@ async fn should_fail_when_the_compact_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact={}", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact={}", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -499,7 +499,7 @@ async fn should_fail_when_the_numwant_param_is_invalid() { for invalid_value in invalid_values { let url = format!( - "announce?info_hash={}&peer_id={}&port={}&peer_addr={}&event=started&compact=0&numwant={}", + "announce?info_hash={}&peer_id={}&port={}&ip={}&event=started&compact=0&numwant={}", default_info_hash, default_peer_id, default_port, "192.168.1.88", invalid_value, ); @@ -689,19 +689,19 @@ 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_peer_addr(peer.peer_addr.ip()) + .with_ip(peer.peer_addr.ip()) .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_peer_addr(peer.peer_addr.ip()) + .with_ip(peer.peer_addr.ip()) .with_port(peer.peer_addr.port()) .query(); // Same peer socket address - assert_eq!(announce_query_1.peer_addr, announce_query_2.peer_addr); + assert_eq!(announce_query_1.ip, announce_query_2.ip); assert_eq!(announce_query_1.port, announce_query_2.port); // Different peer ID @@ -899,11 +899,7 @@ async fn should_not_increase_the_number_of_tcp6_announce_requests_handled_if_the Client::new(env.base_url(), Duration::from_secs(5)) .unwrap() - .announce( - &AnnounceBuilder::default() - .with_peer_addr(IpAddr::V6(Ipv6Addr::LOCALHOST)) - .query(), - ) + .announce(&AnnounceBuilder::default().with_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)).query()) .await .unwrap(); @@ -930,7 +926,7 @@ async fn should_assign_to_the_peer_ip_the_remote_client_ip_instead_of_the_peer_a let announce_query = AnnounceBuilder::default() .with_info_hash(&info_hash) - .with_peer_addr(IpAddr::from_str("2.2.2.2").unwrap()) + .with_ip(IpAddr::from_str("2.2.2.2").unwrap()) .query(); { @@ -974,7 +970,7 @@ async fn when_the_client_ip_is_a_loopback_ipv4_it_should_assign_to_the_peer_ip_t let announce_query = AnnounceBuilder::default() .with_info_hash(&info_hash) - .with_peer_addr(IpAddr::from_str("2.2.2.2").unwrap()) + .with_ip(IpAddr::from_str("2.2.2.2").unwrap()) .query(); { @@ -1027,7 +1023,7 @@ async fn when_the_client_ip_is_a_loopback_ipv6_it_should_assign_to_the_peer_ip_t let announce_query = AnnounceBuilder::default() .with_info_hash(&info_hash) - .with_peer_addr(IpAddr::from_str("2.2.2.2").unwrap()) + .with_ip(IpAddr::from_str("2.2.2.2").unwrap()) .query(); { diff --git a/packages/http-core/benches/helpers/util.rs b/packages/http-core/benches/helpers/util.rs index c9fbf0aac..7faf6f86e 100644 --- a/packages/http-core/benches/helpers/util.rs +++ b/packages/http-core/benches/helpers/util.rs @@ -107,7 +107,7 @@ pub fn sample_announce_request_for_peer(peer: Peer) -> (Announce, ClientIpSource info_hash: sample_info_hash(), peer_id: peer.peer_id, port: peer.peer_addr.port(), - peer_addr: None, + ip: None, uploaded: Some(ProtocolNumberOfBytes::new(peer.uploaded.0)), downloaded: Some(ProtocolNumberOfBytes::new(peer.downloaded.0)), left: Some(ProtocolNumberOfBytes::new(peer.left.0)), diff --git a/packages/http-core/src/services/announce.rs b/packages/http-core/src/services/announce.rs index 67cced9cb..608943534 100644 --- a/packages/http-core/src/services/announce.rs +++ b/packages/http-core/src/services/announce.rs @@ -328,7 +328,7 @@ mod tests { info_hash: sample_info_hash(), peer_id: peer.peer_id, port: peer.peer_addr.port(), - peer_addr: None, + ip: None, uploaded: Some(torrust_tracker_http_protocol::v1::requests::announce::NumberOfBytes::new( peer.uploaded.0, )), diff --git a/packages/http-protocol/src/v1/requests/announce.rs b/packages/http-protocol/src/v1/requests/announce.rs index e825c7d30..4f91e1ca1 100644 --- a/packages/http-protocol/src/v1/requests/announce.rs +++ b/packages/http-protocol/src/v1/requests/announce.rs @@ -29,7 +29,7 @@ const LEFT: &str = "left"; const EVENT: &str = "event"; const COMPACT: &str = "compact"; const NUMWANT: &str = "numwant"; -const PEER_ADDR: &str = "peer_addr"; +const IP: &str = "ip"; // Intentionally protocol-local: this currently mirrors the UDP protocol // `NumberOfBytes` concept and domain byte counters, but it is kept local so @@ -67,7 +67,7 @@ impl NumberOfBytes { /// peer_id: PeerId(*b"-RC3000-000000000001"), /// port: 17548, /// // Optional params -/// peer_addr: None, +/// ip: None, /// downloaded: Some(NumberOfBytes::new(1)), /// uploaded: Some(NumberOfBytes::new(1)), /// left: Some(NumberOfBytes::new(1)), @@ -81,7 +81,7 @@ impl NumberOfBytes { /// > specifies that only the peer `IP` and `event` are optional. However, the /// > tracker defines default values for some of the mandatory params. /// -/// > **NOTICE**: The struct contains `peer_addr` as per BEP 3. The tracker +/// > **NOTICE**: The struct contains `ip` as per BEP 3. The tracker /// > implementation may choose to use it or derive the IP from the connection. #[derive(Clone, Debug, PartialEq)] pub struct Announce { @@ -97,7 +97,7 @@ pub struct Announce { // Optional params /// The peer IP address (BEP 3 `ip` parameter). - pub peer_addr: Option, + pub ip: Option, /// The number of bytes downloaded by the peer. pub downloaded: Option, @@ -291,7 +291,7 @@ impl TryFrom for Announce { event: extract_event(&query)?, compact: extract_compact(&query)?, numwant: extract_numwant(&query)?, - peer_addr: extract_peer_addr(&query), + ip: extract_ip(&query), }) } } @@ -304,8 +304,8 @@ impl fmt::Display for Announce { params.push(("peer_id", percent_encode_byte_array(&self.peer_id.0))); params.push(("port", self.port.to_string())); - if let Some(peer_addr) = &self.peer_addr { - params.push(("peer_addr", peer_addr.to_string())); + if let Some(ip) = &self.ip { + params.push((IP, ip.to_string())); } if let Some(downloaded) = self.downloaded { params.push(("downloaded", downloaded.0.to_string())); @@ -376,7 +376,7 @@ impl AnnounceBuilder { info_hash: InfoHash::from_str("9c38422213e30bff212b30c360d26f9a02136422").unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-qB00000000000000001"), port: 17548, - peer_addr: Some(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 88))), + ip: Some(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 88))), downloaded: None, uploaded: None, left: None, @@ -408,8 +408,8 @@ impl AnnounceBuilder { } #[must_use] - pub fn with_peer_addr(mut self, peer_addr: IpAddr) -> Self { - self.announce.peer_addr = Some(peer_addr); + pub fn with_ip(mut self, ip: IpAddr) -> Self { + self.announce.ip = Some(ip); self } @@ -563,8 +563,8 @@ fn extract_number_of_bytes_from_param(param_name: &str, query: &Query) -> Result } } -fn extract_peer_addr(query: &Query) -> Option { - match query.get_param(PEER_ADDR) { +fn extract_ip(query: &Query) -> Option { + match query.get_param(IP) { Some(raw_param) => IpAddr::from_str(&raw_param).ok(), None => None, } @@ -631,7 +631,7 @@ mod tests { info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-RC3000-000000000001"), port: 17548, - peer_addr: None, + ip: None, downloaded: None, uploaded: None, left: None, @@ -667,7 +667,7 @@ mod tests { info_hash: "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0".parse::().unwrap(), // DevSkim: ignore DS173237 peer_id: PeerId(*b"-RC3000-000000000001"), port: 17548, - peer_addr: None, + ip: None, downloaded: Some(NumberOfBytes::new(1)), uploaded: Some(NumberOfBytes::new(2)), left: Some(NumberOfBytes::new(3)), diff --git a/packages/tracker-core/src/torrent/mod.rs b/packages/tracker-core/src/torrent/mod.rs index af2964fe5..93d2033f1 100644 --- a/packages/tracker-core/src/torrent/mod.rs +++ b/packages/tracker-core/src/torrent/mod.rs @@ -123,7 +123,7 @@ //! Notice that most of the attributes are obtained from the `announce` request. //! For example, an HTTP announce request would contain the following `GET` parameters: //! -//! +//! //! //! The `Tracker` keeps an in-memory ordered data structure with all the torrents and a list of peers for each torrent, together with some swarm metrics. //! diff --git a/src/lib.rs b/src/lib.rs index 4ecbd2561..7190a8302 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -314,7 +314,7 @@ //! //! A sample `announce` request: //! -//! +//! //! //! If you want to know more about the `announce` request: //!