| doc-type | issue | |||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| issue-type | enhancement | |||||||||||||||||||||||||||||||||||
| status | done | |||||||||||||||||||||||||||||||||||
| priority | p2 | |||||||||||||||||||||||||||||||||||
| github-issue | 1640 | |||||||||||||||||||||||||||||||||||
| spec-path | docs/issues/closed/1640-1978-per-http-tracker-on-reverse-proxy-setting.md | |||||||||||||||||||||||||||||||||||
| branch | 1640-move-network-to-per-instance-config | |||||||||||||||||||||||||||||||||||
| related-pr | ||||||||||||||||||||||||||||||||||||
| last-updated-utc | 2026-07-22 00:00 | |||||||||||||||||||||||||||||||||||
| semantic-links |
|
EPIC position: Subissue #3 of 11. Depends on #2 (
tsl→tlstypo fix). Must be implemented before #1417 (public_url) and #1490 (secrets) — both reference theNetworkblock established here. Both #1640 and #1490 touchCore, so #1640 goes first.
Give each tracker instance (HttpTracker and UdpTracker) its own Network config block containing external_ip, on_reverse_proxy, and ipv6_v6only. Remove the shared [core.net] section and make the domain-layer AnnounceHandler accept external_ip as a per-call parameter.
End state: Every tracker instance has its own networking config — socket behaviour, proxy awareness, and peer-IP replacement are all per-instance concerns. The shared Core only holds truly cross-cutting settings (database, policy, private mode).
This issue changes only schema v3.0.0. Schema v2.0.0 remains unchanged in its
separate module for compatibility, but v3_0_0 must exclusively use the per-instance
network fields. It must not deserialize, fall back to, or define precedence for the
removed [core.net] section or the removed flat ipv6_v6only fields.
The application-wide migration from v2 configuration types to v3 configuration types is
the responsibility of EPIC subissue #1980. Once that migration is complete, production
code will use only the v3 per-instance network values. No runtime compatibility bridge
between the v2 and v3 field layouts is required or permitted.
The issue was originally opened to allow per-HTTP-tracker on_reverse_proxy settings. During analysis we discovered a broader architectural problem: the entire Network struct (external_ip, on_reverse_proxy, ipv6_v6only) lived in [core.net] as a global singleton shared by all tracker instances. This caused three separate issues:
| Current field | Currently in | Problem |
|---|---|---|
on_reverse_proxy |
core.net (global) |
HTTP proxy config shouldn't be global — servers differ |
external_ip |
core.net (global) |
Each tracker instance may have its own public IP |
ipv6_v6only |
HttpTracker / UdpTracker (per-instance) |
Correct placement, but field is duplicated in both types |
Final design: Network becomes a per-instance struct placed inside HttpTracker and UdpTracker:
# BEFORE: Global shared config
[core.net]
external_ip = "203.0.113.5"
on_reverse_proxy = true
[[http_trackers]]
bind_address = "0.0.0.0:7070"
ipv6_v6only = false # field directly in HttpTracker
[[udp_trackers]]
bind_address = "0.0.0.0:6969"
ipv6_v6only = true # field directly in UdpTracker
# AFTER: Per-instance networking config
[[http_trackers]]
bind_address = "0.0.0.0:7070"
[http_trackers.network]
external_ip = "203.0.113.5"
on_reverse_proxy = true
ipv6_v6only = false
[[udp_trackers]]
bind_address = "0.0.0.0:6969"
[udp_trackers.network]
external_ip = "2001:db8::1"
on_reverse_proxy = false
ipv6_v6only = trueThe JSON form makes the per-instance structure clearer:
{
"http_trackers": [
{
"bind_address": "0.0.0.0:7070",
"network": {
"external_ip": "203.0.113.5",
"on_reverse_proxy": true,
"ipv6_v6only": false
}
}
],
"udp_trackers": [
{
"bind_address": "0.0.0.0:6969",
"network": {
"external_ip": "2001:db8::1",
"on_reverse_proxy": false,
"ipv6_v6only": true
}
}
]
}The external_ip is consumed by AnnounceHandler::handle_announcement() in tracker-core. It replaces loopback IPs with the tracker's public IP. If you have two tracker instances on different network interfaces with different public IPs, they need different external_ip values. The current global setting cannot express that.
Making external_ip per-instance requires passing it as a parameter to handle_announcement() instead of having the handler read it from self.config — this is architecturally correct: the handler shouldn't know about the server's network topology.
ipv6_v6only controls how the OS socket handles IPv4-mapped IPv6 addresses. It is a networking concern, not a tracker-protocol concern. Grouping it with external_ip and on_reverse_proxy inside a per-instance Network block is more coherent than having it as a flat field in HttpTracker/UdpTracker.
// Per-instance network config — placed inside HttpTracker and UdpTracker
pub struct Network {
pub external_ip: Option<ExternalIp>,
pub on_reverse_proxy: bool,
pub ipv6_v6only: bool,
}
// Server-layer config for each HTTP tracker
pub struct HttpTracker {
pub bind_address: SocketAddr,
pub tls_config: Option<TlsConfig>,
pub tracker_usage_statistics: bool,
pub network: Network, // ← replaces individual fields
// ipv6_v6only REMOVED — now inside network
}
// Server-layer config for each UDP tracker
pub struct UdpTracker {
pub bind_address: SocketAddr,
pub cookie_lifetime: Duration,
pub tracker_usage_statistics: bool,
pub max_connection_id_errors_per_ip: u32,
pub network: Network, // ← replaces individual fields
// ipv6_v6only REMOVED — now inside network
}
// Core — no longer has a network field
pub struct Core {
pub announce_policy: AnnouncePolicy,
pub database: Database,
pub inactive_peer_cleanup_interval: u64,
pub listed: bool,
// network: Network REMOVED
pub private: bool,
pub private_mode: Option<PrivateMode>,
pub tracker_policy: TrackerPolicy,
pub tracker_usage_statistics: bool,
}We considered moving bind_address into Network since it is a networking concern. We decided to keep it flat for two reasons:
- Primary key role:
bind_addressis the HashMap key for tracker instance containers inAppContainer(HashMap<SocketAddr, Arc<HttpTrackerCoreContainer>>). Nesting it insidenetworkwould make lookup more cumbersome without benefit. - TLS asymmetry:
tls_config(TLS certificate paths) cannot go intoNetwork. Keepingbind_addressandtls_configat the same level whileon_reverse_proxy,external_ip, andipv6_v6onlygroup intonetworkcreates a cleaner boundary between socket binding (flat) and socket behaviour / network identity (grouped).
| ADR | Impact | Status |
|---|---|---|
20260617093046 (reject wildcard external_ip) |
ExternalIp newtype unchanged. external_ip moves location (from core.net to http_trackers[].network). The Network struct with its ExternalIp field stays in network.rs as a shared definition. |
✅ Compatible. ADR says "no schema change" — needs updating since this issue changes the location. |
20260620000000 (add ipv6_v6only option) |
Field moves from flat HttpTracker.ipv6_v6only / UdpTracker.ipv6_v6only to HttpTracker.network.ipv6_v6only / UdpTracker.network.ipv6_v6only. Default (false) and behaviour unchanged. |
✅ Compatible. ADR needs updating to reflect new field path. |
20260527175600 (keep protocol/domain decoupled) |
Not directly related — this issue touches configuration types and service-layer code, not protocol types. | ✅ No impact. |
This is a breaking configuration change. Users upgrading to the new tracker version (4.0.0) must update their tracker.toml:
Note on versioning: The tracker application and the configuration schema use independent version systems. The tracker app goes from 3.0.0 → 4.0.0, while the config schema goes from 2.0.0 → 3.0.0. This allows them to evolve independently — the configuration crate can also be used partially in other projects.
Before:
[core.net]
external_ip = "203.0.113.5"
on_reverse_proxy = true
[[http_trackers]]
bind_address = "0.0.0.0:7070"
ipv6_v6only = falseAfter:
[[http_trackers]]
bind_address = "0.0.0.0:7070"
[http_trackers.network]
external_ip = "203.0.113.5"
on_reverse_proxy = true
ipv6_v6only = falseThe old [core.net] section is no longer valid. Each tracker instance has its own Network configuration. The TOML network block is optional and defaults to external_ip = None, on_reverse_proxy = false, and ipv6_v6only = false when omitted. The external_ip and on_reverse_proxy values must be moved into each configured [[http_trackers]].network (and/or [[udp_trackers]].network) block.
The per-instance Network block is a natural home for additional per-tracker networking fields in future issues. Relevant candidates from related work:
From the Torrust Tracker Deployer
The deployer's environment configs (e.g. 02-full-stack-lxd.json) already include per-tracker metadata that the tracker configuration does not yet support:
{
"http_trackers": [
{
"bind_address": "0.0.0.0:7070",
"domain": "tracker1.example.com",
"use_tls_proxy": true
},
{
"bind_address": "0.0.0.0:7071",
"domain": "tracker2.example.com",
"use_tls_proxy": true
}
]
}These fields (domain, use_tls_proxy) describe how each tracker instance is exposed to the public internet — a networking concern that fits naturally into per-instance config.
Note on TLS vs reverse proxy: There are two independent TLS configurations:
tls_configonHttpTracker— the tracker terminates TLS directly (clients connect via HTTPS directly to the tracker). No proxy involved.use_tls_proxyin the deployer — TLS is terminated at a reverse proxy (Caddy, nginx) before forwarding plain HTTP to the tracker.Both are orthogonal to
on_reverse_proxy(trustingX-Forwarded-Forheaders). You can have:
- Direct HTTPS tracker (
tls_configset) with or without trusting proxy headers- Tracker behind a TLS proxy (
use_tls_proxy) withon_reverse_proxy = true(common case)- Tracker behind a plain HTTP proxy (no TLS) with
on_reverse_proxy = true- Tracker directly exposed via plain HTTP without any proxy
This issue only addresses
on_reverse_proxy; TLS configuration remains a separate concern.
Issue #1417 adds an optional public_url: Option<String> field to each tracker instance (HttpTracker, UdpTracker) and API service (HttpApi, HealthCheckApi). This field is implemented in this EPIC (not a future extension) but lives as a flat field on each config struct — not inside Network.
Why flat, not inside Network: The Network block groups network topology concerns (how the tracker connects: external IP, proxy awareness, socket behaviour). public_url is about public exposure (how users reach the tracker). It's a different axis — one tracker instance might have both a network.on_reverse_proxy setting and a public_url, and they are independently configurable.
[[http_trackers]]
bind_address = "0.0.0.0:7070"
public_url = "https://tracker.torrust-demo.com/announce"
[http_trackers.network]
external_ip = "203.0.113.5"
on_reverse_proxy = true
ipv6_v6only = falseDesign decision (July 2026): The field is a full URL string ("https://tracker1.example.com/announce"). The URL protocol is validated: HTTP trackers must use http:// or https://, UDP trackers must use udp://. This is simpler than decomposed fields (domain + path) and consumers can parse the URL as needed. The full URL also subsumes the deployer's domain + use_tls_proxy approach — the protocol tells us if TLS is used, and the domain is extracted from the URL.
Below is how the full types would look after this issue's changes plus #1417 (public_url). Fields marked † are implemented in this issue; fields marked ‡ are implemented in #1417.
/// Per-instance network topology config.
/// Grouped because these fields together define how the tracker instance
/// connects to the network — the external identity, proxy awareness, and
/// socket behaviour.
pub struct Network { // † this issue
pub external_ip: Option<ExternalIp>, // † from core.net
pub on_reverse_proxy: bool, // † from core.net
pub ipv6_v6only: bool, // † from flat field
}
/// Server-layer config for each HTTP tracker.
pub struct HttpTracker {
// Socket binding — how the OS binds the listener
pub bind_address: SocketAddr,
pub tls_config: Option<TlsConfig>, // direct TLS (tracker terminates)
// Instance metadata
pub tracker_usage_statistics: bool,
// Public exposure — how users reach this tracker
pub public_url: Option<String>, // ‡ #1417 — full URL (e.g. "https://tracker1.example.com/announce")
// Network topology (grouped)
pub network: Network, // † new
}
/// Server-layer config for each UDP tracker.
pub struct UdpTracker {
pub bind_address: SocketAddr,
pub cookie_lifetime: Duration,
pub tracker_usage_statistics: bool,
pub max_connection_id_errors_per_ip: u32,
// Public exposure — how users reach this tracker
pub public_url: Option<String>, // ‡ #1417 — full URL (e.g. "udp://tracker1.example.com:6969")
// Network topology (grouped)
pub network: Network, // † new
}
/// Core — no longer has any networking config.
pub struct Core {
pub announce_policy: AnnouncePolicy,
pub database: Database,
pub inactive_peer_cleanup_interval: u64,
pub listed: bool,
// network: Network REMOVED †
pub private: bool,
pub private_mode: Option<PrivateMode>,
pub tracker_policy: TrackerPolicy,
pub tracker_usage_statistics: bool,
}Rationale for keeping public_url flat (not inside Network):
The Network block groups network topology concerns — how the tracker instance connects to the network (external IP, proxy awareness, socket behaviour). public_url is about public exposure — how users reach the tracker. These are different axes:
- A tracker behind a reverse proxy might have
network.on_reverse_proxy = trueandpublic_url = "https://tracker.example.com/announce" - A directly-exposed tracker might have
network.on_reverse_proxy = falseandpublic_url = "http://tracker.example.com:7070/announce" - Both fields are independently configurable; nesting one inside the other would be misleading
The AnnounceHandler in tracker-core stops reading the global configuration's external_ip and instead accepts it as a parameter:
pub async fn handle_announcement(
&self,
info_hash: &InfoHash,
peer: &mut peer::Peer,
remote_client_ip: &IpAddr,
peers_wanted: &PeersWanted,
tracker_external_ip: Option<IpAddr>, // NEW: passed in from caller
) -> Result<AnnounceData, AnnounceError> {
...
peer.change_ip(&assign_ip_address_to_peer(remote_client_ip, tracker_external_ip));
...
}- Add
network: Network(withexternal_ip,on_reverse_proxy,ipv6_v6only) as an optional-in-TOML, per-instance field in bothHttpTrackerandUdpTracker - Remove
NetworkfromCore(removecore.netentirely) - Modify
AnnounceHandler::handle_announcement()to acceptexternal_ipper-call instead of reading from global config - Update all callers of
handle_announcement()(HTTP services, UDP services, tests) to pass per-instanceexternal_ip - Update all consumers of
ipv6_v6onlyto read fromHttpTracker.network/UdpTracker.networkinstead of flat struct fields - Remove deprecated flat
ipv6_v6onlyfields fromHttpTrackerandUdpTracker - Update v3 configuration tests, docs, and doc comments
- Write ADR for the architecture decision
- TOML config migration tooling
- Migrating application consumers, test helpers, or default configuration files from schema v2 to v3 (subissue #1980)
- Supporting removed v2 fields in schema v3 or defining old-versus-new field precedence
For the on_reverse_proxy threading, we use Approach B (as analysed earlier): each HttpTrackerCoreContainer creates per-instance AnnounceService and ScrapeService storing their own ReverseProxyMode. This avoids extending Axum state tuples and keeps handler signatures stable. The full analysis is preserved below in the appendix.
Write the Architectural Decision Record documenting:
- Why
Networkmoves from globalcore.netto per-instance configs - Why
external_ipbecomes a parameter ofhandle_announcement() - Why
ipv6_v6onlyjoinsNetwork
Add the new network: Network field to both tracker config structs. Remove core.net and the
flat ipv6_v6only fields from v3 at the same time. Network gains ipv6_v6only. The TOML
block is optional and deserializes to the safe defaults below when omitted.
Default for Network:
Network {
external_ip: None,
on_reverse_proxy: false,
ipv6_v6only: false,
}Verification: V3 configuration deserializes with an omitted network block and rejects the
removed v2 field layout. Schema v2 tests remain unchanged.
Add tracker_external_ip: Option<IpAddr> parameter to handle_announcement(). V3 consumers
pass their instance's network.external_ip; no caller reads core.net.
Verification: All handle_announcement() call sites compile. No behaviour change.
This is the largest phase, split into sub-tasks (each committed and CI-verified independently):
test-helpers: Set per-trackeron_reverse_proxyinHttpTracker.networkinstead ofcore.nethttp-core/services/announce.rs+scrape.rs: Read from per-instanceReverseProxyMode(Approach B)HttpTrackerCoreServices+HttpTrackerCoreContainer: Create per-instance servicessrc/container.rs: Flow per-instance mode throughAppContainer- Unit/integration tests: Update all references to per-tracker
HttpTrackerconsumers (server.rs,environment.rs,bootstrap/jobs/http_tracker.rs, contract tests): Read fromhttp_tracker_config.network.ipv6_v6onlyUdpTrackerconsumers (launcher.rs, contract tests): Read fromudp_tracker_config.network.ipv6_v6only
udp-servertests: Pass per-trackerexternal_iptohandle_announcement()(now available fromudp_tracker_config.network.external_ip)http-coretests: Pass per-trackerexternal_iptohandle_announcement()(now available fromhttp_tracker_config.network.external_ip)axum-http-servercontract tests: Same
- Delete
core.netfromCorestruct. Keepnetwork.rswith bothNetworkandExternalIp— bothHttpTrackerandUdpTrackerimportNetworkfrom there (single definition, no duplication). - Delete flat
ipv6_v6onlyfields fromHttpTrackerandUdpTracker - Delete
get_ext_ip()fromConfiguration(no longer needed — each instance has its ownexternal_ip) - Update v3 doc comments and crate-level docs
linter all- Full test suite
- Manual verification of mixed proxy/non-proxy scenarios
- Close the draft PR and open the final PR
Chosen approach: Approach B (per-instance services with reverse_proxy_mode field) for on_reverse_proxy threading.
| ID | Phase | Status | Task | Notes |
|---|---|---|---|---|
| T0 | 0 | DONE | Write ADR | 20260721000000_make_network_configuration_per_tracker_instance.md |
| T1 | 1 | DONE | Define v3 network: Network (with ipv6_v6only) in HttpTracker and UdpTracker |
Removed v2 fields are rejected in v3; TOML block defaults safely when omitted |
| T2 | 2 | DEFERRED | Add tracker_external_ip param to handle_announcement() |
Requires active runtime consumers to migrate to v3 in #1980 |
| T3a | 3a | DEFERRED | Switch on_reverse_proxy consumers to per-instance |
Requires active runtime consumers to migrate to v3 in #1980 |
| T3b | 3b | DEFERRED | Switch ipv6_v6only consumers to network.ipv6_v6only |
Requires active runtime consumers to migrate to v3 in #1980 |
| T3c | 3c | DEFERRED | Switch external_ip consumers |
Requires active runtime consumers to migrate to v3 in #1980 |
| T4 | 4 | DONE | Remove deprecated fields from v3 | Removed core.net, flat ipv6_v6only, and get_ext_ip() |
| T5 | 4 | DONE | Update v3 documentation and doc comments | V3 configuration module, ADR, and issue specification |
| T7 | 5 | PARTIAL | Run linter all and full test suite |
linter all and cargo test -p torrust-tracker-configuration pass; full suite deferred to #1980 |
| T8 | 6 | DONE | Update migration guide if this subissue affects the config public API | docs/issues/open/1978-configuration-overhaul-epic/configuration-v2-to-v3-migration.md |
- Spec drafted in
docs/issues/open/ - Spec reviewed and approved by user/maintainer
- Phase 0: ADR created
- Phase 1: v3
network: Networkreplacescore.netand flatipv6_v6only - Phase 2:
handle_announcement()acceptstracker_external_ipparam - Phase 3a:
on_reverse_proxyconsumers switched to per-instance - Phase 3b:
ipv6_v6onlyconsumers switched tonetwork.ipv6_v6only - Phase 3c:
external_ipconsumers switched to per-instance - Phase 4: V3 schema boundary complete (
core.net, flatipv6_v6only,get_ext_ip()removed) - Phase 5: Final verification completed (
linter all, full test suite) - Manual verification scenarios executed and recorded (status + evidence)
- 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
- Issue closed and spec moved from
docs/issues/open/todocs/issues/closed/
- 2026-07-22 00:00 UTC - agent - Verified issue #1640 is CLOSED on GitHub and archived this spec to docs/issues/closed/.
Append one line per meaningful update.
- 2026-06-23 00:00 UTC - Copilot - Spec drafted from issue #1640
- 2026-06-23 14:00 UTC - Copilot - Added design decision analysis (Approach A vs B) after maintainer review
- 2026-06-23 14:30 UTC - Copilot - Updated spec: remove global
[core.net].on_reverse_proxy, move to per-trackerHttpTracker.on_reverse_proxy: bool. Added ADR task T1. - 2026-06-23 16:00 UTC - Copilot - Rewrote spec with full architectural vision: per-instance
Networkfor all three fields, phased implementation with baby steps + draft PR. - 2026-06-23 17:45 UTC - Copilot - Added design note on
bind_addressstaying flat, future extensions section (domain,use_tls_proxy,public_url) referencing deployer and issue #1417. - 2026-06-23 18:30 UTC - Copilot - Completed deep review against ADRs 20260617093046, 20260620000000, 20260527175600 and issues #1417, #1671. Added compatibility table and migration note.
- 2026-07-14 00:00 UTC - josecelano - Resolved #1417 relationship:
public_urlis in this EPIC (not future), stays flat (not insideNetwork). Replaced "Future Extensions" section with "Related Issue: #1417" section. Updated config types to showpublic_urlas‡field. Added versioning note (app 4.0.0, config schema 3.0.0). - 2026-07-21 00:00 UTC - josecelano - Confirmed
networkas the per-instance field name, aligned with theNetworktype. Confirmed the TOML[*.network]block is optional and defaults toexternal_ip = None,on_reverse_proxy = false, andipv6_v6only = false. - 2026-07-21 00:00 UTC - josecelano - Confirmed the schema compatibility boundary: v3 accepts only per-instance
networkfields and has no fallback or precedence for removed v2 fields. Application migration to v3 remains subissue #1980. - 2026-07-21 00:00 UTC - agent - Implemented the v3 schema slice: per-tracker
networkdefaults, removed v3 global and flat fields, strict old-layout rejection tests, and ADR. Active runtime consumers remain on v2 and are deferred to #1980. - 2026-07-21 12:00 UTC - agent - Marked DONE: PR #2014 merged; v3 schema slice is in
develop. Runtime consumer tasks (T2–T3c:handle_announcementparam,on_reverse_proxy/ipv6_v6only/external_ipconsumer switch) are tracked under subissue #11 (#1980).
- AC1:
on_reverse_proxyis removed from[core.net]and placed per-instance inHttpTracker.network.on_reverse_proxy(andUdpTracker.network.on_reverse_proxyfor future UDP proxy use) - AC2:
external_ipis removed from[core.net]and placed per-instance inHttpTracker.network.external_ipandUdpTracker.network.external_ip - AC3:
ipv6_v6onlyis moved from flatHttpTracker.ipv6_v6onlyandUdpTracker.ipv6_v6onlyintoHttpTracker.network/UdpTracker.network - AC4:
Core.net(theNetworkstruct) is removed fromCore - AC5:
AnnounceHandler::handle_announcement()acceptstracker_external_ipper-call instead of reading from global config - AC6: Two HTTP trackers with different
on_reverse_proxysettings behave independently: - Tracker A (on_reverse_proxy = true) readsX-Forwarded-Forheaders - Tracker B (on_reverse_proxy = falseor unset) reads connection info IP - AC7: Example
http_only_public_tracker.rsbuilds with the newHttpTracker.network.on_reverse_proxyfield - AC8: V3 configuration documentation uses the new format; active application default configuration migration is deferred to #1980
- AC9: Schema v3 rejects
[core.net]and flat trackeripv6_v6onlyfields; it does not define old-versus-new precedence -
linter allexits with code0 - Relevant tests pass
- Manual verification scenarios are executed and documented (status + evidence)
- Acceptance criteria are re-reviewed after implementation and reflect actual behavior